Add contract management and invoice settings pages
- Created a new Blade view for managing brand contracts with forms for adding and editing contracts, including client and category selection. - Implemented success message notifications for contract actions. - Added a new Blade view for invoice settings, allowing customization of invoice number format, header branding, and default values for new invoices. - Introduced a new PDF view for generating rate cards with customizable styles based on user profile settings. - Developed a profile management page for rate cards, enabling users to update their identity, appearance, and visibility settings.
This commit is contained in:
126
app/Http/Controllers/BrandContractController.php
Normal file
126
app/Http/Controllers/BrandContractController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BrandContract;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCategory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class BrandContractController extends Controller
|
||||
{
|
||||
private function categoryTree(): \Illuminate\Support\Collection
|
||||
{
|
||||
return ClientCategory::with('children')
|
||||
->whereNull('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$contracts = BrandContract::with(['client', 'category'])
|
||||
->orderByDesc('end_date')
|
||||
->get();
|
||||
$clients = Client::orderBy('name')->get();
|
||||
$categoryTree = $this->categoryTree();
|
||||
|
||||
return view('contracts.index', compact('contracts', 'clients', 'categoryTree'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'client_id' => 'nullable|exists:clients,id',
|
||||
'category_id' => 'required|exists:client_categories,id',
|
||||
'start_date' => 'required|date',
|
||||
'end_date' => 'required|date|after_or_equal:start_date',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['is_active'] = true;
|
||||
BrandContract::create($validated);
|
||||
|
||||
return back()->with('success', '📝 Contract added!');
|
||||
}
|
||||
|
||||
public function update(Request $request, BrandContract $contract): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'client_id' => 'nullable|exists:clients,id',
|
||||
'category_id' => 'required|exists:client_categories,id',
|
||||
'start_date' => 'required|date',
|
||||
'end_date' => 'required|date|after_or_equal:start_date',
|
||||
'notes' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['is_active'] = $request->boolean('is_active', $contract->is_active);
|
||||
$contract->update($validated);
|
||||
|
||||
return back()->with('success', '📝 Contract updated!');
|
||||
}
|
||||
|
||||
public function destroy(BrandContract $contract): RedirectResponse
|
||||
{
|
||||
$contract->delete();
|
||||
return back()->with('success', '📝 Contract removed.');
|
||||
}
|
||||
|
||||
public function toggle(BrandContract $contract): RedirectResponse
|
||||
{
|
||||
$contract->update(['is_active' => ! $contract->is_active]);
|
||||
return back()->with('success', $contract->is_active ? '✅ Contract activated.' : '⏸️ Contract paused.');
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: check if a category has an active exclusive contract.
|
||||
* Returns null if clear, or contract details if blocked.
|
||||
*/
|
||||
public function check(Request $request): JsonResponse
|
||||
{
|
||||
$categoryId = $request->integer('category_id');
|
||||
$clientId = $request->integer('client_id');
|
||||
|
||||
if (! $categoryId) {
|
||||
return response()->json(null);
|
||||
}
|
||||
|
||||
// Collect the category and its parent chain (so blocking a parent also blocks children)
|
||||
$category = ClientCategory::find($categoryId);
|
||||
$checkIds = [$categoryId];
|
||||
if ($category?->parent_id) {
|
||||
$checkIds[] = $category->parent_id;
|
||||
}
|
||||
|
||||
$contract = BrandContract::with(['client', 'category'])
|
||||
->active()
|
||||
->whereIn('category_id', $checkIds)
|
||||
->first();
|
||||
|
||||
if (! $contract) {
|
||||
return response()->json(null);
|
||||
}
|
||||
|
||||
// Allow if it's the contracted brand itself
|
||||
if ($clientId && $contract->client_id && $contract->client_id === $clientId) {
|
||||
return response()->json(null);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'id' => $contract->id,
|
||||
'title' => $contract->title,
|
||||
'client_name' => $contract->client?->name ?? 'Unknown',
|
||||
'category' => $contract->category?->name ?? 'Unknown',
|
||||
'end_date' => $contract->end_date->format('d M Y'),
|
||||
'days_left' => $contract->days_remaining,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,18 @@ use Illuminate\Http\Request;
|
||||
|
||||
class ClientCategoryController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(): \Illuminate\View\View
|
||||
{
|
||||
// Managed inline from the clients page; redirect there
|
||||
return redirect()->route('clients.index');
|
||||
$categories = ClientCategory::with('children', 'parent')
|
||||
->withCount('clients')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
$parents = ClientCategory::whereNull('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
return view('categories.index', compact('categories', 'parents'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\ContentStatus;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCategory;
|
||||
use App\Models\ContentBrief;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -14,6 +15,14 @@ use Illuminate\View\View;
|
||||
|
||||
class ContentBriefController extends Controller
|
||||
{
|
||||
private function categoryTree(): \Illuminate\Support\Collection
|
||||
{
|
||||
return ClientCategory::with('children')
|
||||
->whereNull('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
@@ -32,9 +41,10 @@ class ContentBriefController extends Controller
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = ContentStatus::cases();
|
||||
return view('content.create', compact('clients', 'statuses'));
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = ContentStatus::cases();
|
||||
$categoryTree = $this->categoryTree();
|
||||
return view('content.create', compact('clients', 'statuses', 'categoryTree'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
@@ -42,6 +52,7 @@ class ContentBriefController extends Controller
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'client_id' => 'nullable|exists:clients,id',
|
||||
'category_id' => 'nullable|exists:client_categories,id',
|
||||
'platform' => 'required|in:instagram,tiktok,youtube',
|
||||
'content_type' => 'required|string|max:50',
|
||||
'status' => 'required|in:drafting,filming,editing,review,posted,archived',
|
||||
@@ -69,10 +80,16 @@ class ContentBriefController extends Controller
|
||||
|
||||
public function edit(ContentBrief $contentBrief): View
|
||||
{
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = ContentStatus::cases();
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = ContentStatus::cases();
|
||||
$categoryTree = $this->categoryTree();
|
||||
$contentBrief->load('client');
|
||||
return view('content.edit', ['brief' => $contentBrief, 'clients' => $clients, 'statuses' => $statuses]);
|
||||
return view('content.edit', [
|
||||
'brief' => $contentBrief,
|
||||
'clients' => $clients,
|
||||
'statuses' => $statuses,
|
||||
'categoryTree' => $categoryTree,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, ContentBrief $contentBrief): RedirectResponse
|
||||
@@ -80,6 +97,7 @@ class ContentBriefController extends Controller
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'client_id' => 'nullable|exists:clients,id',
|
||||
'category_id' => 'nullable|exists:client_categories,id',
|
||||
'platform' => 'required|in:instagram,tiktok,youtube',
|
||||
'content_type' => 'required|string|max:50',
|
||||
'status' => 'required|in:drafting,filming,editing,review,posted,archived',
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCategory;
|
||||
use App\Models\Event;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -14,10 +15,19 @@ use Illuminate\View\View;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
private function categoryTree(): \Illuminate\Support\Collection
|
||||
{
|
||||
return ClientCategory::with('children')
|
||||
->whereNull('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$events = Event::with('client')->orderBy('start_time')->get();
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$events = Event::with('client')->orderBy('start_time')->get();
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$categoryTree = $this->categoryTree();
|
||||
|
||||
// Format for FullCalendar
|
||||
$calendarEvents = $events->map(fn (Event $e) => [
|
||||
@@ -35,7 +45,7 @@ class EventController extends Controller
|
||||
],
|
||||
]);
|
||||
|
||||
return view('schedule.index', compact('events', 'clients', 'calendarEvents'));
|
||||
return view('schedule.index', compact('events', 'clients', 'calendarEvents', 'categoryTree'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
@@ -44,6 +54,7 @@ class EventController extends Controller
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'client_id' => 'nullable|exists:clients,id',
|
||||
'category_id' => 'nullable|exists:client_categories,id',
|
||||
'event_type' => 'required|in:task,meeting,deadline,posting',
|
||||
'start_time' => 'required|date',
|
||||
'end_time' => 'nullable|date|after:start_time',
|
||||
@@ -65,10 +76,11 @@ class EventController extends Controller
|
||||
|
||||
public function edit(Event $event): View
|
||||
{
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = EventStatus::cases();
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = EventStatus::cases();
|
||||
$categoryTree = $this->categoryTree();
|
||||
|
||||
return view('schedule.edit', compact('event', 'clients', 'statuses'));
|
||||
return view('schedule.edit', compact('event', 'clients', 'statuses', 'categoryTree'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Event $event): RedirectResponse
|
||||
@@ -77,6 +89,7 @@ class EventController extends Controller
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'client_id' => 'nullable|exists:clients,id',
|
||||
'category_id' => 'nullable|exists:client_categories,id',
|
||||
'event_type' => 'required|in:task,meeting,deadline,posting',
|
||||
'start_time' => 'required|date',
|
||||
'end_time' => 'nullable|date',
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Models\Client;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceSettings;
|
||||
use App\Services\InvoiceService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -27,9 +28,10 @@ class InvoiceController extends Controller
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = InvoiceStatus::cases();
|
||||
return view('invoices.create', compact('clients', 'statuses'));
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = InvoiceStatus::cases();
|
||||
$invoiceSettings = InvoiceSettings::instance();
|
||||
return view('invoices.create', compact('clients', 'statuses', 'invoiceSettings'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
@@ -43,13 +45,15 @@ class InvoiceController extends Controller
|
||||
'issue_date' => 'required|date',
|
||||
'due_date' => 'required|date|after_or_equal:issue_date',
|
||||
'notes' => 'nullable|string',
|
||||
'bank_name' => 'nullable|string|max:100',
|
||||
'bank_name' => 'nullable|string|max:100',
|
||||
'bank_account' => 'nullable|string|max:50',
|
||||
'bank_holder' => 'nullable|string|max:100',
|
||||
'items' => 'required|array|min:1',
|
||||
'bank_holder' => 'nullable|string|max:100',
|
||||
'branch_name' => 'nullable|string|max:100',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string',
|
||||
'items.*.quantity' => 'required|integer|min:1',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
'items.*.is_taxable' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$items = $validated['items'];
|
||||
@@ -65,15 +69,17 @@ class InvoiceController extends Controller
|
||||
public function show(Invoice $invoice): View
|
||||
{
|
||||
$invoice->load('client', 'items');
|
||||
return view('invoices.show', compact('invoice'));
|
||||
$invoiceSettings = InvoiceSettings::instance();
|
||||
return view('invoices.show', compact('invoice', 'invoiceSettings'));
|
||||
}
|
||||
|
||||
public function edit(Invoice $invoice): View
|
||||
{
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = InvoiceStatus::cases();
|
||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||
$statuses = InvoiceStatus::cases();
|
||||
$invoiceSettings = InvoiceSettings::instance();
|
||||
$invoice->load('client', 'items');
|
||||
return view('invoices.edit', compact('invoice', 'clients', 'statuses'));
|
||||
return view('invoices.edit', compact('invoice', 'clients', 'statuses', 'invoiceSettings'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Invoice $invoice): RedirectResponse
|
||||
@@ -87,14 +93,16 @@ class InvoiceController extends Controller
|
||||
'issue_date' => 'required|date',
|
||||
'due_date' => 'required|date',
|
||||
'paid_date' => 'nullable|date',
|
||||
'notes' => 'nullable|string',
|
||||
'bank_name' => 'nullable|string|max:100',
|
||||
'notes' => 'nullable|string',
|
||||
'bank_name' => 'nullable|string|max:100',
|
||||
'bank_account' => 'nullable|string|max:50',
|
||||
'bank_holder' => 'nullable|string|max:100',
|
||||
'items' => 'required|array|min:1',
|
||||
'bank_holder' => 'nullable|string|max:100',
|
||||
'branch_name' => 'nullable|string|max:100',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string',
|
||||
'items.*.quantity' => 'required|integer|min:1',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
'items.*.is_taxable' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$items = $validated['items'];
|
||||
|
||||
55
app/Http/Controllers/InvoiceSettingsController.php
Normal file
55
app/Http/Controllers/InvoiceSettingsController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\InvoiceSettings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class InvoiceSettingsController extends Controller
|
||||
{
|
||||
public function edit(): View
|
||||
{
|
||||
$settings = InvoiceSettings::instance();
|
||||
return view('invoices.settings', compact('settings'));
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'invoice_prefix' => 'required|string|max:20',
|
||||
'invoice_next_number' => 'required|integer|min:1',
|
||||
'header_name' => 'nullable|string|max:100',
|
||||
'header_title' => 'nullable|string|max:150',
|
||||
'header_logo' => 'nullable|image|max:2048',
|
||||
'default_tax_rate' => 'nullable|numeric|min:0|max:100',
|
||||
'default_currency' => 'nullable|string|max:10',
|
||||
'default_service_description'=> 'nullable|string|max:255',
|
||||
'default_bank_name' => 'nullable|string|max:100',
|
||||
'default_branch_name' => 'nullable|string|max:100',
|
||||
'default_bank_account' => 'nullable|string|max:50',
|
||||
'default_bank_holder' => 'nullable|string|max:100',
|
||||
'default_notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$settings = InvoiceSettings::instance();
|
||||
|
||||
if ($request->hasFile('header_logo')) {
|
||||
// Delete old logo if exists
|
||||
if ($settings->header_logo) {
|
||||
Storage::disk('public')->delete($settings->header_logo);
|
||||
}
|
||||
$path = $request->file('header_logo')->store('invoice-logos', 'public');
|
||||
$validated['header_logo'] = $path;
|
||||
}
|
||||
|
||||
$settings->update($validated);
|
||||
|
||||
return redirect()->route('invoice-settings.edit')
|
||||
->with('success', '🧾 Invoice settings saved!');
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class MediaFileController extends Controller
|
||||
return view('files.index', compact('files', 'folders', 'folder', 'clients'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(Request $request): RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|max:51200', // 50 MB
|
||||
@@ -55,6 +55,10 @@ class MediaFileController extends Controller
|
||||
$request->input('client_id')
|
||||
);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json(['success' => true, 'message' => 'File uploaded successfully!']);
|
||||
}
|
||||
|
||||
return back()->with('success', '📁 File uploaded successfully!');
|
||||
}
|
||||
|
||||
|
||||
@@ -23,26 +23,26 @@ class NotificationSettingController extends Controller
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'channel' => 'required|in:email,telegram,whatsapp',
|
||||
'triggers' => 'nullable|array',
|
||||
'triggers.*' => 'in:upcoming_event,invoice_due,content_due',
|
||||
'notify_before_hours' => 'nullable|integer|min:1|max:720',
|
||||
'name' => 'required|string|max:100',
|
||||
'channel' => 'required|in:email,telegram,whatsapp',
|
||||
'triggers' => 'nullable|array',
|
||||
'triggers.*' => 'in:upcoming_event,invoice_due,content_due',
|
||||
'notify_before_hours' => 'nullable|integer|min:1|max:720',
|
||||
// Email
|
||||
'config.to_email' => 'required_if:channel,email|nullable|email',
|
||||
'config.to_email' => 'required_if:channel,email|nullable|email',
|
||||
// Telegram
|
||||
'config.bot_token' => 'required_if:channel,telegram|nullable|string',
|
||||
'config.chat_id' => 'required_if:channel,telegram|nullable|string',
|
||||
'config.bot_token' => 'required_if:channel,telegram|nullable|string',
|
||||
'config.chat_id' => 'nullable|string',
|
||||
// WhatsApp (WAHA)
|
||||
'config.waha_url' => 'required_if:channel,whatsapp|nullable|url',
|
||||
'config.waha_session' => 'required_if:channel,whatsapp|nullable|string',
|
||||
'config.phone' => 'required_if:channel,whatsapp|nullable|string',
|
||||
'config.waha_endpoint' => 'required_if:channel,whatsapp|nullable|url',
|
||||
'config.waha_session' => 'nullable|string',
|
||||
'config.api_key' => 'nullable|string',
|
||||
]);
|
||||
|
||||
NotificationSetting::create([
|
||||
'name' => $validated['name'],
|
||||
'channel' => $validated['channel'],
|
||||
'config' => $validated['config'] ?? [],
|
||||
'config' => $request->input('config', []), // pass raw config — not stripped by validation
|
||||
'triggers' => $validated['triggers'] ?? [],
|
||||
'notify_before_hours' => $validated['notify_before_hours'] ?? 24,
|
||||
'is_enabled' => true,
|
||||
@@ -65,7 +65,7 @@ class NotificationSettingController extends Controller
|
||||
'name' => $validated['name'],
|
||||
'triggers' => $validated['triggers'] ?? [],
|
||||
'notify_before_hours' => $validated['notify_before_hours'] ?? $setting->notify_before_hours,
|
||||
'config' => $validated['config'] ?? $setting->config,
|
||||
'config' => $request->input('config', $setting->config), // use raw input so no keys are stripped
|
||||
]);
|
||||
|
||||
return back()->with('success', '🔔 Notification updated!');
|
||||
|
||||
@@ -5,8 +5,11 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\RateCard;
|
||||
use App\Models\RateCardProfile;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RateCardController extends Controller
|
||||
@@ -14,7 +17,8 @@ class RateCardController extends Controller
|
||||
public function index(): View
|
||||
{
|
||||
$rateCards = RateCard::orderBy('sort_order')->orderBy('platform')->get();
|
||||
return view('rate-cards.index', compact('rateCards'));
|
||||
$profile = RateCardProfile::getSingleton();
|
||||
return view('rate-cards.index', compact('rateCards', 'profile'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
@@ -25,15 +29,15 @@ class RateCardController extends Controller
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'service_name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'platform' => 'required|in:instagram,tiktok,youtube,general',
|
||||
'content_type' => 'nullable|string|max:50',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'currency' => 'required|string|max:10',
|
||||
'price_unit' => 'required|in:per_content,per_month,per_campaign',
|
||||
'service_name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'platform' => 'required|in:instagram,tiktok,youtube,general',
|
||||
'content_type' => 'nullable|string|max:50',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'currency' => 'required|string|max:10',
|
||||
'price_unit' => 'required|in:per_content,per_month,per_campaign',
|
||||
'is_negotiable' => 'boolean',
|
||||
'sort_order' => 'nullable|integer',
|
||||
'sort_order' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$validated['is_negotiable'] = $request->boolean('is_negotiable');
|
||||
@@ -53,16 +57,16 @@ class RateCardController extends Controller
|
||||
public function update(Request $request, RateCard $rateCard): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'service_name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'platform' => 'required|in:instagram,tiktok,youtube,general',
|
||||
'content_type' => 'nullable|string|max:50',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'currency' => 'required|string|max:10',
|
||||
'price_unit' => 'required|in:per_content,per_month,per_campaign',
|
||||
'service_name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'platform' => 'required|in:instagram,tiktok,youtube,general',
|
||||
'content_type' => 'nullable|string|max:50',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'currency' => 'required|string|max:10',
|
||||
'price_unit' => 'required|in:per_content,per_month,per_campaign',
|
||||
'is_negotiable' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'nullable|integer',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$validated['is_negotiable'] = $request->boolean('is_negotiable');
|
||||
@@ -80,14 +84,91 @@ class RateCardController extends Controller
|
||||
return redirect()->route('rate-cards.index')->with('success', 'Entry removed.');
|
||||
}
|
||||
|
||||
// ── Public Page ──────────────────────────────────────────────
|
||||
|
||||
public function public(): View
|
||||
{
|
||||
$profile = RateCardProfile::getSingleton();
|
||||
$rateCards = RateCard::where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('platform')
|
||||
->get()
|
||||
->groupBy('platform');
|
||||
|
||||
return view('rate-cards.public', compact('rateCards'));
|
||||
return view('rate-cards.public', compact('rateCards', 'profile'));
|
||||
}
|
||||
|
||||
// ── Profile Settings ─────────────────────────────────────────
|
||||
|
||||
public function profilePage(): View
|
||||
{
|
||||
$profile = RateCardProfile::getSingleton();
|
||||
return view('rate-cards.profile', compact('profile'));
|
||||
}
|
||||
|
||||
public function profileUpdate(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'owner_name' => 'required|string|max:100',
|
||||
'tagline' => 'nullable|string|max:200',
|
||||
'whatsapp' => 'nullable|string|max:30',
|
||||
'ig_handle' => 'nullable|string|max:60',
|
||||
'tiktok_handle' => 'nullable|string|max:60',
|
||||
'youtube_handle'=> 'nullable|string|max:60',
|
||||
'email' => 'nullable|email|max:150',
|
||||
'bg_color' => 'nullable|string|max:20',
|
||||
'accent_color' => 'nullable|string|max:20',
|
||||
'text_color' => 'nullable|string|max:20',
|
||||
'custom_footer' => 'nullable|string|max:500',
|
||||
'show_prices' => 'boolean',
|
||||
'logo' => 'nullable|image|max:2048',
|
||||
]);
|
||||
|
||||
$profile = RateCardProfile::getSingleton();
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$path = $request->file('logo')->store('profile', 'private');
|
||||
$validated['logo_path'] = $path;
|
||||
}
|
||||
|
||||
$validated['show_prices'] = $request->boolean('show_prices');
|
||||
unset($validated['logo']);
|
||||
|
||||
$profile->update($validated);
|
||||
|
||||
return back()->with('success', '🎨 Profile updated!');
|
||||
}
|
||||
|
||||
public function serveProfileLogo(): Response
|
||||
{
|
||||
$profile = RateCardProfile::getSingleton();
|
||||
|
||||
if (! $profile->logo_path || ! \Storage::disk('private')->exists($profile->logo_path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$contents = \Storage::disk('private')->get($profile->logo_path);
|
||||
$mime = \Storage::disk('private')->mimeType($profile->logo_path);
|
||||
|
||||
return response($contents, 200)->header('Content-Type', $mime);
|
||||
}
|
||||
|
||||
// ── PDF Download ─────────────────────────────────────────────
|
||||
|
||||
public function pdf(): \Illuminate\Http\Response
|
||||
{
|
||||
$profile = RateCardProfile::getSingleton();
|
||||
$rateCards = RateCard::where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('platform')
|
||||
->get()
|
||||
->groupBy('platform');
|
||||
|
||||
$pdf = Pdf::loadView('rate-cards.pdf', compact('rateCards', 'profile'))
|
||||
->setPaper('a4', 'portrait');
|
||||
|
||||
$filename = 'rate-card-' . now()->format('Y-m-d') . '.pdf';
|
||||
|
||||
return $pdf->download($filename);
|
||||
}
|
||||
}
|
||||
|
||||
75
app/Models/BrandContract.php
Normal file
75
app/Models/BrandContract.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BrandContract extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'title', 'client_id', 'category_id',
|
||||
'start_date', 'end_date', 'notes', 'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// ── Relationships ────────────────────────────────────────────
|
||||
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
// ── Scopes ───────────────────────────────────────────────────
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true)
|
||||
->where('start_date', '<=', today())
|
||||
->where('end_date', '>=', today());
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
public function isCurrentlyActive(): bool
|
||||
{
|
||||
return $this->is_active
|
||||
&& $this->start_date->lte(today())
|
||||
&& $this->end_date->gte(today());
|
||||
}
|
||||
|
||||
public function getDaysRemainingAttribute(): int
|
||||
{
|
||||
return (int) max(0, today()->diffInDays($this->end_date, false));
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
if (! $this->is_active) return 'Inactive';
|
||||
if ($this->start_date->gt(today())) return 'Upcoming';
|
||||
if ($this->end_date->lt(today())) return 'Expired';
|
||||
return 'Active';
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->statusLabel) {
|
||||
'Active' => 'badge-success',
|
||||
'Upcoming' => 'badge-info',
|
||||
'Expired' => 'badge-neutral',
|
||||
default => 'badge-ghost',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class ContentBrief extends Model
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'client_id', 'title', 'platform', 'content_type', 'status',
|
||||
'client_id', 'category_id', 'title', 'platform', 'content_type', 'status',
|
||||
'hook', 'body', 'cta', 'script', 'caption', 'hashtags',
|
||||
'target_date', 'posted_date', 'posted_url', 'sort_order', 'notes',
|
||||
];
|
||||
@@ -31,6 +31,11 @@ class ContentBrief extends Model
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function getPlatformIconAttribute(): string
|
||||
{
|
||||
return match($this->platform) {
|
||||
|
||||
@@ -15,7 +15,7 @@ class Event extends Model
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'client_id', 'title', 'description', 'event_type',
|
||||
'client_id', 'category_id', 'title', 'description', 'event_type',
|
||||
'start_time', 'end_time', 'location', 'is_priority',
|
||||
'is_all_day', 'status', 'color', 'notes',
|
||||
];
|
||||
@@ -33,6 +33,11 @@ class Event extends Model
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClientCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function scopeUpcoming($query, int $hours = 24)
|
||||
{
|
||||
return $query->where('start_time', '>=', now())
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Models;
|
||||
|
||||
use App\Enums\InvoiceStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
// InvoiceSettings used for number generation
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -19,7 +20,7 @@ class Invoice extends Model
|
||||
'client_id', 'invoice_number', 'title', 'description',
|
||||
'subtotal', 'tax_rate', 'tax_amount', 'total', 'currency',
|
||||
'status', 'issue_date', 'due_date', 'paid_date', 'notes',
|
||||
'bank_name', 'bank_account', 'bank_holder',
|
||||
'bank_name', 'bank_account', 'bank_holder', 'branch_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -45,9 +46,15 @@ class Invoice extends Model
|
||||
|
||||
public function recalculate(): void
|
||||
{
|
||||
$this->subtotal = $this->items->sum('total');
|
||||
$this->tax_amount = round($this->subtotal * ($this->tax_rate / 100), 2);
|
||||
$this->total = $this->subtotal + $this->tax_amount;
|
||||
// Total of all items (what the creator receives)
|
||||
$this->subtotal = $this->items->sum('total');
|
||||
|
||||
// Tax is calculated only on taxable items, but paid by the client on top
|
||||
$taxableSubtotal = $this->items->where('is_taxable', true)->sum('total');
|
||||
$this->tax_amount = round((float) $taxableSubtotal * ($this->tax_rate / 100), 2);
|
||||
|
||||
// Grand total = subtotal (creator's fee, pre-tax — tax is borne by client separately)
|
||||
$this->total = $this->subtotal;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
@@ -58,8 +65,6 @@ class Invoice extends Model
|
||||
|
||||
public static function generateNumber(): string
|
||||
{
|
||||
$prefix = 'INV-' . now()->format('Ym');
|
||||
$last = static::where('invoice_number', 'like', $prefix . '%')->count();
|
||||
return $prefix . '-' . str_pad((string) ($last + 1), 3, '0', STR_PAD_LEFT);
|
||||
return InvoiceSettings::generateNumber();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InvoiceItem extends Model
|
||||
{
|
||||
protected $fillable = ['invoice_id', 'description', 'quantity', 'unit_price', 'total'];
|
||||
protected $fillable = ['invoice_id', 'description', 'quantity', 'unit_price', 'total', 'is_taxable'];
|
||||
|
||||
protected $casts = [
|
||||
'unit_price' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'is_taxable' => 'boolean',
|
||||
];
|
||||
|
||||
public function invoice(): BelongsTo
|
||||
|
||||
54
app/Models/InvoiceSettings.php
Normal file
54
app/Models/InvoiceSettings.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InvoiceSettings extends Model
|
||||
{
|
||||
protected $table = 'invoice_settings';
|
||||
|
||||
protected $fillable = [
|
||||
'invoice_prefix',
|
||||
'invoice_next_number',
|
||||
'header_name',
|
||||
'header_title',
|
||||
'header_logo',
|
||||
'default_tax_rate',
|
||||
'default_currency',
|
||||
'default_service_description',
|
||||
'default_bank_name',
|
||||
'default_branch_name',
|
||||
'default_bank_account',
|
||||
'default_bank_holder',
|
||||
'default_notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'invoice_next_number' => 'integer',
|
||||
'default_tax_rate' => 'decimal:2',
|
||||
];
|
||||
|
||||
/** Always return the single settings row, creating it if absent. */
|
||||
public static function instance(): self
|
||||
{
|
||||
return static::firstOrCreate([], [
|
||||
'invoice_prefix' => 'INV',
|
||||
'invoice_next_number' => 1,
|
||||
'default_tax_rate' => 11,
|
||||
'default_currency' => 'IDR',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Generate the next invoice number and increment the counter. */
|
||||
public static function generateNumber(): string
|
||||
{
|
||||
$settings = static::instance();
|
||||
$prefix = rtrim($settings->invoice_prefix, '-');
|
||||
$number = str_pad((string) $settings->invoice_next_number, 3, '0', STR_PAD_LEFT);
|
||||
$settings->increment('invoice_next_number');
|
||||
return $prefix . '-' . $number;
|
||||
}
|
||||
}
|
||||
47
app/Models/RateCardProfile.php
Normal file
47
app/Models/RateCardProfile.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class RateCardProfile extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'owner_name', 'tagline', 'whatsapp',
|
||||
'ig_handle', 'tiktok_handle', 'youtube_handle', 'email',
|
||||
'logo_path', 'bg_color', 'accent_color', 'text_color',
|
||||
'custom_footer', 'show_prices', 'access_token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'show_prices' => 'boolean',
|
||||
];
|
||||
|
||||
/** Get the singleton profile, creating defaults if needed */
|
||||
public static function getSingleton(): self
|
||||
{
|
||||
return static::firstOrCreate([], [
|
||||
'owner_name' => config('app.name', 'KOL Manager'),
|
||||
'bg_color' => '#FFF8F0',
|
||||
'accent_color' => '#FFB7C5',
|
||||
'text_color' => '#374151',
|
||||
'show_prices' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getWhatsappUrlAttribute(): ?string
|
||||
{
|
||||
if (! $this->whatsapp) return null;
|
||||
$number = preg_replace('/[^0-9]/', '', $this->whatsapp);
|
||||
return "https://wa.me/{$number}";
|
||||
}
|
||||
|
||||
public function getLogoUrlAttribute(): ?string
|
||||
{
|
||||
return $this->logo_path
|
||||
? route('files.serve-profile-logo')
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\BrandContract;
|
||||
use App\Models\Client;
|
||||
use App\Models\ClientCategory;
|
||||
use App\Models\ContentBrief;
|
||||
use App\Models\Event;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\MediaFile;
|
||||
use App\Models\SocialStat;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
@@ -21,7 +24,12 @@ final class DashboardService
|
||||
'upcoming_events' => Event::upcoming(24 * 90)->count(), // 3 months
|
||||
'pending_invoices' => Invoice::where('status', 'sent')->count(),
|
||||
'total_revenue' => Invoice::where('status', 'paid')->sum('total'),
|
||||
'content_in_progress' => ContentBrief::whereNotIn('status', ['posted', 'archived'])->count(),
|
||||
'content_in_progress' => ContentBrief::whereNotIn('status', ['posted', 'archived'])->count(),
|
||||
'total_categories' => ClientCategory::whereNull('parent_id')->count(),
|
||||
'total_subcategories' => ClientCategory::whereNotNull('parent_id')->count(),
|
||||
'total_files' => MediaFile::count(),
|
||||
'active_contracts' => BrandContract::where('is_active', true)->where('end_date', '>=', today())->count(),
|
||||
'total_contracts' => BrandContract::count(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Services;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceItem;
|
||||
use App\Models\InvoiceSettings;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
@@ -22,7 +23,8 @@ final class InvoiceService
|
||||
]);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$item['total'] = $item['quantity'] * $item['unit_price'];
|
||||
$item['total'] = $item['quantity'] * $item['unit_price'];
|
||||
$item['is_taxable'] = isset($item['is_taxable']) && $item['is_taxable'] ? true : false;
|
||||
$invoice->items()->create($item);
|
||||
}
|
||||
|
||||
@@ -37,7 +39,8 @@ final class InvoiceService
|
||||
$invoice->items()->delete();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$item['total'] = $item['quantity'] * $item['unit_price'];
|
||||
$item['total'] = $item['quantity'] * $item['unit_price'];
|
||||
$item['is_taxable'] = isset($item['is_taxable']) && $item['is_taxable'] ? true : false;
|
||||
$invoice->items()->create($item);
|
||||
}
|
||||
|
||||
@@ -49,8 +52,9 @@ final class InvoiceService
|
||||
public function generatePdf(Invoice $invoice): Response
|
||||
{
|
||||
$invoice->load('client', 'items');
|
||||
$invoiceSettings = InvoiceSettings::instance();
|
||||
|
||||
$pdf = Pdf::loadView('invoices.pdf', compact('invoice'));
|
||||
$pdf = Pdf::loadView('invoices.pdf', compact('invoice', 'invoiceSettings'));
|
||||
$pdf->setPaper('A4', 'portrait');
|
||||
|
||||
return $pdf->download("invoice-{$invoice->invoice_number}.pdf");
|
||||
|
||||
@@ -30,26 +30,33 @@ class WahaNotifier
|
||||
|
||||
private function send(NotificationSetting $setting, string $text): void
|
||||
{
|
||||
$config = $setting->config ?? [];
|
||||
$url = rtrim($config['waha_url'] ?? '', '/');
|
||||
$session = $config['waha_session'] ?? 'default';
|
||||
$phone = $config['phone'] ?? null;
|
||||
$config = $setting->config ?? [];
|
||||
$endpoint = $config['waha_endpoint'] ?? null;
|
||||
$session = $config['waha_session'] ?? 'default';
|
||||
$chatId = $config['chat_id'] ?? null;
|
||||
$apiKey = $config['api_key'] ?? null;
|
||||
|
||||
if (! $url || ! $phone) {
|
||||
throw new \RuntimeException('WAHA waha_url and phone are required.');
|
||||
if (! $endpoint || ! $chatId) {
|
||||
throw new \RuntimeException('WAHA waha_endpoint and chat_id are required.');
|
||||
}
|
||||
|
||||
// Normalise phone: strip non-digits, ensure no leading +
|
||||
$chatId = preg_replace('/[^0-9]/', '', $phone) . '@c.us';
|
||||
$http = Http::timeout(15)->acceptJson()->contentType('application/json');
|
||||
|
||||
$response = Http::timeout(15)->post("{$url}/api/sendText", [
|
||||
'session' => $session,
|
||||
if ($apiKey) {
|
||||
$http = $http->withHeaders(['X-Api-Key' => $apiKey]);
|
||||
}
|
||||
|
||||
$response = $http->post($endpoint, [
|
||||
'chatId' => $chatId,
|
||||
'text' => $text,
|
||||
'session' => $session,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException('WAHA API error: ' . $response->body());
|
||||
$keyHint = $apiKey ? '(api_key set, length=' . strlen($apiKey) . ')' : '(no api_key)';
|
||||
throw new \RuntimeException(
|
||||
"WAHA API error {$response->status()} {$keyHint} → " . $response->body()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user