diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bad401d..aca9b84 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,8 @@ "Bash(NODE_BIN=\"/c/laragon/bin/nodejs/node-v22\" cd \"E:/_PERSONAL-PROJECT_/PHP/KOL-Manager\" \"$NODE_BIN/node.exe\" \"$NODE_BIN/node_modules/npm/bin/npm-cli.js\" run build)", "Bash(/c/laragon/bin/nodejs/node-v22/node node_modules/npm/bin/npm-cli.js run build)", "Bash(/c/laragon/bin/nodejs/node-v22/node /c/laragon/bin/nodejs/node-v22/node_modules/npm/bin/npm-cli.js run build)", - "Bash(php artisan:*)" + "Bash(php artisan:*)", + "Bash(php -r \":*)" ] } } diff --git a/app/Http/Controllers/BrandContractController.php b/app/Http/Controllers/BrandContractController.php new file mode 100644 index 0000000..542241f --- /dev/null +++ b/app/Http/Controllers/BrandContractController.php @@ -0,0 +1,126 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/ClientCategoryController.php b/app/Http/Controllers/ClientCategoryController.php index 46cfad7..773fc5a 100644 --- a/app/Http/Controllers/ClientCategoryController.php +++ b/app/Http/Controllers/ClientCategoryController.php @@ -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 diff --git a/app/Http/Controllers/ContentBriefController.php b/app/Http/Controllers/ContentBriefController.php index 7afa338..dc80d58 100644 --- a/app/Http/Controllers/ContentBriefController.php +++ b/app/Http/Controllers/ContentBriefController.php @@ -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', diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index 851b285..a7c9b0b 100644 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -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', diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index 8d0d7f4..8904bc4 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -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']; diff --git a/app/Http/Controllers/InvoiceSettingsController.php b/app/Http/Controllers/InvoiceSettingsController.php new file mode 100644 index 0000000..01e3e03 --- /dev/null +++ b/app/Http/Controllers/InvoiceSettingsController.php @@ -0,0 +1,55 @@ +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!'); + } +} diff --git a/app/Http/Controllers/MediaFileController.php b/app/Http/Controllers/MediaFileController.php index 0ba8a52..a3a9699 100644 --- a/app/Http/Controllers/MediaFileController.php +++ b/app/Http/Controllers/MediaFileController.php @@ -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!'); } diff --git a/app/Http/Controllers/NotificationSettingController.php b/app/Http/Controllers/NotificationSettingController.php index b18c6b2..7c5c3f3 100644 --- a/app/Http/Controllers/NotificationSettingController.php +++ b/app/Http/Controllers/NotificationSettingController.php @@ -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!'); diff --git a/app/Http/Controllers/RateCardController.php b/app/Http/Controllers/RateCardController.php index 75d90aa..8f08c87 100644 --- a/app/Http/Controllers/RateCardController.php +++ b/app/Http/Controllers/RateCardController.php @@ -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); } } diff --git a/app/Models/BrandContract.php b/app/Models/BrandContract.php new file mode 100644 index 0000000..7f0ae5e --- /dev/null +++ b/app/Models/BrandContract.php @@ -0,0 +1,75 @@ + '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', + }; + } +} diff --git a/app/Models/ContentBrief.php b/app/Models/ContentBrief.php index 8fdda2f..3980230 100644 --- a/app/Models/ContentBrief.php +++ b/app/Models/ContentBrief.php @@ -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) { diff --git a/app/Models/Event.php b/app/Models/Event.php index 634e56e..d909c85 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -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()) diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index ba9952d..9db9979 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -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(); } } diff --git a/app/Models/InvoiceItem.php b/app/Models/InvoiceItem.php index 2f6ec88..7affe3c 100644 --- a/app/Models/InvoiceItem.php +++ b/app/Models/InvoiceItem.php @@ -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 diff --git a/app/Models/InvoiceSettings.php b/app/Models/InvoiceSettings.php new file mode 100644 index 0000000..cf81fac --- /dev/null +++ b/app/Models/InvoiceSettings.php @@ -0,0 +1,54 @@ + '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; + } +} diff --git a/app/Models/RateCardProfile.php b/app/Models/RateCardProfile.php new file mode 100644 index 0000000..2678bed --- /dev/null +++ b/app/Models/RateCardProfile.php @@ -0,0 +1,47 @@ + '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; + } +} diff --git a/app/Services/DashboardService.php b/app/Services/DashboardService.php index a24210e..cea6ed2 100644 --- a/app/Services/DashboardService.php +++ b/app/Services/DashboardService.php @@ -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(), ]; } diff --git a/app/Services/InvoiceService.php b/app/Services/InvoiceService.php index 9eed952..bcb1ccd 100644 --- a/app/Services/InvoiceService.php +++ b/app/Services/InvoiceService.php @@ -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"); diff --git a/app/Services/Notifications/WahaNotifier.php b/app/Services/Notifications/WahaNotifier.php index 3d08b2e..76b15fb 100644 --- a/app/Services/Notifications/WahaNotifier.php +++ b/app/Services/Notifications/WahaNotifier.php @@ -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() + ); } } } diff --git a/database/migrations/2024_01_01_000100_add_category_to_events_and_content_briefs.php b/database/migrations/2024_01_01_000100_add_category_to_events_and_content_briefs.php new file mode 100644 index 0000000..dde9060 --- /dev/null +++ b/database/migrations/2024_01_01_000100_add_category_to_events_and_content_briefs.php @@ -0,0 +1,37 @@ +unsignedBigInteger('category_id')->nullable()->after('client_id'); + $table->foreign('category_id') + ->references('id')->on('client_categories')->onDelete('set null'); + }); + + Schema::table('content_briefs', function (Blueprint $table): void { + $table->unsignedBigInteger('category_id')->nullable()->after('client_id'); + $table->foreign('category_id') + ->references('id')->on('client_categories')->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::table('events', function (Blueprint $table): void { + $table->dropForeign(['category_id']); + $table->dropColumn('category_id'); + }); + Schema::table('content_briefs', function (Blueprint $table): void { + $table->dropForeign(['category_id']); + $table->dropColumn('category_id'); + }); + } +}; diff --git a/database/migrations/2024_01_01_000110_create_brand_contracts_table.php b/database/migrations/2024_01_01_000110_create_brand_contracts_table.php new file mode 100644 index 0000000..c2b2e71 --- /dev/null +++ b/database/migrations/2024_01_01_000110_create_brand_contracts_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('title'); + $table->unsignedBigInteger('client_id')->nullable(); // the contracted brand + $table->unsignedBigInteger('category_id')->nullable(); // the exclusive category + $table->date('start_date'); + $table->date('end_date'); + $table->text('notes')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->foreign('client_id')->references('id')->on('clients')->onDelete('set null'); + $table->foreign('category_id')->references('id')->on('client_categories')->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::dropIfExists('brand_contracts'); + } +}; diff --git a/database/migrations/2024_01_01_000120_create_rate_card_profiles_table.php b/database/migrations/2024_01_01_000120_create_rate_card_profiles_table.php new file mode 100644 index 0000000..049f192 --- /dev/null +++ b/database/migrations/2024_01_01_000120_create_rate_card_profiles_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('owner_name')->default('KOL Manager'); + $table->string('tagline')->nullable(); + $table->string('whatsapp')->nullable(); + $table->string('ig_handle')->nullable(); + $table->string('tiktok_handle')->nullable(); + $table->string('youtube_handle')->nullable(); + $table->string('email')->nullable(); + $table->string('logo_path')->nullable(); // uploaded image + $table->string('bg_color')->default('#FFF8F0'); + $table->string('accent_color')->default('#FFB7C5'); + $table->string('text_color')->default('#374151'); + $table->text('custom_footer')->nullable(); + $table->boolean('show_prices')->default(true); + $table->string('access_token')->nullable(); // optional: require ?token=xxx + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('rate_card_profiles'); + } +}; diff --git a/database/migrations/2026_04_01_104816_add_invoice_settings_and_branch_name.php b/database/migrations/2026_04_01_104816_add_invoice_settings_and_branch_name.php new file mode 100644 index 0000000..b6c6268 --- /dev/null +++ b/database/migrations/2026_04_01_104816_add_invoice_settings_and_branch_name.php @@ -0,0 +1,45 @@ +string('branch_name')->nullable()->after('bank_holder'); + }); + + // Add is_taxable to invoice_items + Schema::table('invoice_items', function (Blueprint $table): void { + $table->boolean('is_taxable')->default(true)->after('total'); + }); + + // Invoice settings singleton table + Schema::create('invoice_settings', function (Blueprint $table): void { + $table->id(); + $table->string('invoice_prefix')->default('INV'); + $table->unsignedInteger('invoice_next_number')->default(1); + $table->string('header_name')->nullable(); // talent name + $table->string('header_title')->nullable(); // subtitle / tagline + $table->string('header_logo')->nullable(); // stored path + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoice_settings'); + Schema::table('invoice_items', function (Blueprint $table): void { + $table->dropColumn('is_taxable'); + }); + Schema::table('invoices', function (Blueprint $table): void { + $table->dropColumn('branch_name'); + }); + } +}; diff --git a/database/migrations/2026_04_01_143218_add_defaults_to_invoice_settings.php b/database/migrations/2026_04_01_143218_add_defaults_to_invoice_settings.php new file mode 100644 index 0000000..4e78759 --- /dev/null +++ b/database/migrations/2026_04_01_143218_add_defaults_to_invoice_settings.php @@ -0,0 +1,36 @@ +decimal('default_tax_rate', 5, 2)->default(11)->after('header_logo'); + $table->string('default_currency')->default('IDR')->after('default_tax_rate'); + $table->string('default_service_description')->nullable()->after('default_currency'); + $table->string('default_bank_name')->nullable()->after('default_service_description'); + $table->string('default_branch_name')->nullable()->after('default_bank_name'); + $table->string('default_bank_account')->nullable()->after('default_branch_name'); + $table->string('default_bank_holder')->nullable()->after('default_bank_account'); + $table->text('default_notes')->nullable()->after('default_bank_holder'); + }); + } + + public function down(): void + { + Schema::table('invoice_settings', function (Blueprint $table): void { + $table->dropColumn([ + 'default_tax_rate', 'default_currency', + 'default_service_description', + 'default_bank_name', 'default_branch_name', + 'default_bank_account', 'default_bank_holder', 'default_notes', + ]); + }); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index d6d3437..ea619bd 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -115,6 +115,40 @@ aside { } .paw-bounce { animation: paw-bounce 0.4s ease-in-out; } +/* ββ Label Soft βββββββββββββββββββββββββββββββββββββββββββββ */ +.label-soft { + @apply block text-xs font-semibold text-gray-600 mb-1; +} + +/* ββ Select Soft ββββββββββββββββββββββββββββββββββββββββββββ */ +.select-soft { + @apply rounded-2xl border border-pink-200 px-4 py-2 + focus:border-pink-400 focus:outline-none focus:ring-2 focus:ring-pink-200 + w-full bg-white text-sm; +} + +/* ββ Btn Pink (alias for btn-sakura) βββββββββββββββββββββββ */ +.btn-pink { + @apply rounded-full text-white font-semibold shadow-sm + transition-all duration-150 inline-block cursor-pointer text-center; + background-color: #FFB7C5; + border: none; + text-decoration: none; +} +.btn-pink:hover { background-color: #FF8FAD; } +.btn-pink:active { transform: scale(0.96); } + +/* ββ Badge Soft βββββββββββββββββββββββββββββββββββββββββββββ */ +.badge-soft { @apply badge badge-ghost; } + +/* ββ Cat Card Header ββββββββββββββββββββββββββββββββββββββββ */ +.cat-card-header { + @apply flex items-center gap-3 px-4 py-3 border-b border-pink-50; +} +.cat-card-icon { + @apply text-xl shrink-0; +} + /* ββ Mobile touch targets βββββββββββββββββββββββββββββββββββ */ @media (max-width: 1023px) { .nav-item { @apply py-3.5; } diff --git a/resources/views/categories/index.blade.php b/resources/views/categories/index.blade.php new file mode 100644 index 0000000..3b14e78 --- /dev/null +++ b/resources/views/categories/index.blade.php @@ -0,0 +1,213 @@ +@extends('layouts.app') +@section('title', 'Categories') +@section('page-title', 'π·οΈ Category Management') +@section('page-subtitle', 'Organise clients, schedules & content by category') + +@section('content') +
No categories yet. Add your first one above.
+No contracts yet. Add your first exclusive agreement above.
+No files yet.
-e.g. INV-EA β INV-EA-001
Used for the next new invoice
+Current Logo
+Upload a new file to replace it
+Max 2 MB. Shown at the top-left of your invoice.
+These values will pre-fill every new invoice you create.
+ +| Service | +Description | +Price | +
|---|---|---|
|
+ {{ $card->service_name }}
+ @if($card->content_type)
+ {{ $card->content_type }}
+ @endif
+ |
+
+ @if($card->description)
+ {{ $card->description }}
+ @endif
+ |
+
+ @if($profile->show_prices)
+ {{ $card->formatted_price }}
+ {{ str_replace('_', ' ', $card->price_unit) }}
+ @else
+ On Request
+ @endif
+ @if($card->is_negotiable)
+ Negotiable
+ @endif
+ |
+
Max 2MB. PNG/JPG/WebP.
+Full URL to the sendText endpoint
Sent as X-Api-Key header. Leave blank if WAHA has no auth.
Individual: 628xxx@c.us Β· Group: 120363xxx@g.us
Your self-hosted WAHA instance
+Full URL to the sendText endpoint of your WAHA instance
Sent as X-Api-Key header. Leave blank if WAHA has no auth.
Individual: 628xxx@c.us Β· Group: 120363xxx@g.us