diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 585252c..bad401d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,8 @@ "Bash(PHP_BIN=\"/c/laragon/bin/php/php-8.3.16-Win32-vs16-x64/php.exe\" cd \"E:/_PERSONAL-PROJECT_/PHP/KOL-Manager\" __NEW_LINE_7c463fa4c32b98a2__ \"$PHP_BIN\" artisan route:list)", "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(/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:*)" ] } } diff --git a/app/Http/Controllers/ClientCategoryController.php b/app/Http/Controllers/ClientCategoryController.php new file mode 100644 index 0000000..46cfad7 --- /dev/null +++ b/app/Http/Controllers/ClientCategoryController.php @@ -0,0 +1,71 @@ +route('clients.index'); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'parent_id' => 'nullable|exists:client_categories,id', + 'icon' => 'nullable|string|max:10', + 'color' => 'nullable|string|max:20', + ]); + + // Auto slug + $validated['slug'] = \Illuminate\Support\Str::slug($validated['name']); + + // If slug conflicts, suffix with random + if (ClientCategory::where('slug', $validated['slug'])->exists()) { + $validated['slug'] .= '-' . substr(uniqid(), -4); + } + + ClientCategory::create($validated); + + return back()->with('success', '๐ท๏ธ Category added!'); + } + + public function update(Request $request, ClientCategory $category): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'parent_id' => 'nullable|exists:client_categories,id', + 'icon' => 'nullable|string|max:10', + 'color' => 'nullable|string|max:20', + ]); + + // Prevent a category from being its own parent + if (isset($validated['parent_id']) && $validated['parent_id'] == $category->id) { + $validated['parent_id'] = null; + } + + $category->update($validated); + + return back()->with('success', '๐ท๏ธ Category updated!'); + } + + public function destroy(ClientCategory $category): RedirectResponse + { + // Reassign children to the parent + $category->children()->update(['parent_id' => $category->parent_id]); + // Detach clients + $category->clients()->update(['category_id' => null]); + + $category->delete(); + + return back()->with('success', '๐ท๏ธ Category removed.'); + } +} diff --git a/app/Http/Controllers/ClientController.php b/app/Http/Controllers/ClientController.php index 3fc3858..8707970 100644 --- a/app/Http/Controllers/ClientController.php +++ b/app/Http/Controllers/ClientController.php @@ -6,25 +6,61 @@ namespace App\Http\Controllers; use App\Enums\ClientStatus; use App\Models\Client; +use App\Models\ClientCategory; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; class ClientController extends Controller { - public function index(): View + private function categoryTree(): \Illuminate\Support\Collection { - $clients = Client::withCount(['invoices', 'events', 'contentBriefs']) - ->latest() - ->paginate(12); + return ClientCategory::with('children') + ->whereNull('parent_id') + ->orderBy('sort_order') + ->get(); + } - return view('clients.index', compact('clients')); + private function flatCategories(): \Illuminate\Support\Collection + { + $flat = collect(); + foreach ($this->categoryTree() as $parent) { + $flat->push($parent); + foreach ($parent->children as $child) { + $flat->push($child); + } + } + return $flat; + } + + public function index(Request $request): View + { + $query = Client::with('category') + ->withCount(['invoices', 'events', 'contentBriefs']) + ->latest(); + + if ($request->filled('category')) { + $query->where('category_id', $request->category); + } + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $clients = $query->paginate(12)->withQueryString(); + $categories = $this->categoryTree(); + $statuses = ClientStatus::cases(); + + return view('clients.index', compact('clients', 'categories', 'statuses')); } public function create(): View { - $statuses = ClientStatus::cases(); - return view('clients.create', compact('statuses')); + $statuses = ClientStatus::cases(); + $categories = $this->flatCategories(); + $categoryTree = $this->categoryTree(); + + return view('clients.create', compact('statuses', 'categories', 'categoryTree')); } public function store(Request $request): RedirectResponse @@ -44,6 +80,7 @@ class ClientController extends Controller 'postal_code' => 'nullable|string|max:20', 'country' => 'nullable|string|max:100', 'status' => 'required|in:active,inactive,prospect', + 'category_id' => 'nullable|exists:client_categories,id', 'notes' => 'nullable|string', ]); @@ -55,18 +92,24 @@ class ClientController extends Controller public function show(Client $client): View { - $client->load(['events' => fn ($q) => $q->latest()->limit(5)]); - $client->load(['invoices' => fn ($q) => $q->latest()->limit(5)]); - $client->load(['contentBriefs' => fn ($q) => $q->latest()->limit(5)]); - $client->load(['mediaFiles' => fn ($q) => $q->latest()->limit(10)]); + $client->load([ + 'category', + 'events' => fn ($q) => $q->latest()->limit(5), + 'invoices' => fn ($q) => $q->latest()->limit(5), + 'contentBriefs'=> fn ($q) => $q->latest()->limit(5), + 'mediaFiles' => fn ($q) => $q->latest()->limit(10), + ]); return view('clients.show', compact('client')); } public function edit(Client $client): View { - $statuses = ClientStatus::cases(); - return view('clients.edit', compact('client', 'statuses')); + $statuses = ClientStatus::cases(); + $categories = $this->flatCategories(); + $categoryTree = $this->categoryTree(); + + return view('clients.edit', compact('client', 'statuses', 'categories', 'categoryTree')); } public function update(Request $request, Client $client): RedirectResponse @@ -86,6 +129,7 @@ class ClientController extends Controller 'postal_code' => 'nullable|string|max:20', 'country' => 'nullable|string|max:100', 'status' => 'required|in:active,inactive,prospect', + 'category_id' => 'nullable|exists:client_categories,id', 'notes' => 'nullable|string', ]); diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index 581c3af..851b285 100644 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -63,6 +63,14 @@ class EventController extends Controller ->with('success', '๐พ Event scheduled!'); } + public function edit(Event $event): View + { + $clients = Client::where('status', 'active')->orderBy('name')->get(); + $statuses = EventStatus::cases(); + + return view('schedule.edit', compact('event', 'clients', 'statuses')); + } + public function update(Request $request, Event $event): RedirectResponse { $validated = $request->validate([ diff --git a/app/Http/Controllers/NotificationSettingController.php b/app/Http/Controllers/NotificationSettingController.php new file mode 100644 index 0000000..b18c6b2 --- /dev/null +++ b/app/Http/Controllers/NotificationSettingController.php @@ -0,0 +1,97 @@ +orderBy('name')->get(); + + return view('settings.notifications', compact('settings')); + } + + 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', + // 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', + // 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', + ]); + + NotificationSetting::create([ + 'name' => $validated['name'], + 'channel' => $validated['channel'], + 'config' => $validated['config'] ?? [], + 'triggers' => $validated['triggers'] ?? [], + 'notify_before_hours' => $validated['notify_before_hours'] ?? 24, + 'is_enabled' => true, + ]); + + return back()->with('success', '๐ Notification channel added!'); + } + + public function update(Request $request, NotificationSetting $setting): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:100', + 'triggers' => 'nullable|array', + 'triggers.*' => 'in:upcoming_event,invoice_due,content_due', + 'notify_before_hours' => 'nullable|integer|min:1|max:720', + 'config' => 'nullable|array', + ]); + + $setting->update([ + 'name' => $validated['name'], + 'triggers' => $validated['triggers'] ?? [], + 'notify_before_hours' => $validated['notify_before_hours'] ?? $setting->notify_before_hours, + 'config' => $validated['config'] ?? $setting->config, + ]); + + return back()->with('success', '๐ Notification updated!'); + } + + public function destroy(NotificationSetting $setting): RedirectResponse + { + $setting->delete(); + + return back()->with('success', '๐ Channel removed.'); + } + + public function toggle(NotificationSetting $setting): RedirectResponse + { + $setting->update(['is_enabled' => ! $setting->is_enabled]); + + return back()->with('success', $setting->is_enabled ? '๐ Enabled!' : '๐ Disabled.'); + } + + public function test(NotificationSetting $setting): RedirectResponse + { + try { + app(NotificationDispatcher::class)->sendTest($setting); + return back()->with('success', 'โ Test notification sent!'); + } catch (\Throwable $e) { + return back()->with('error', 'โ Failed: ' . $e->getMessage()); + } + } +} diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php new file mode 100644 index 0000000..9b037d9 --- /dev/null +++ b/app/Http/Controllers/SearchController.php @@ -0,0 +1,143 @@ +get('q', '')); + $results = $q ? $this->search($q) : []; + + return view('search.index', compact('q', 'results')); + } + + /** Quick AJAX endpoint โ returns grouped results for the dropdown */ + public function quick(Request $request): JsonResponse + { + $q = trim($request->get('q', '')); + + if (strlen($q) < 2) { + return response()->json([]); + } + + return response()->json($this->search($q, limit: 5)); + } + + private function search(string $q, int $limit = 20): array + { + $groups = []; + $like = "%{$q}%"; + + // โโ Clients โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + $clients = Client::where('name', 'LIKE', $like) + ->orWhere('email', 'LIKE', $like) + ->orWhere('company', 'LIKE', $like) + ->orWhere('ig_handle', 'LIKE', $like) + ->orWhere('tiktok_handle', 'LIKE', $like) + ->limit($limit)->get(); + + if ($clients->isNotEmpty()) { + $groups[] = [ + 'type' => 'clients', + 'label' => '๐ฅ Clients', + 'items' => $clients->map(fn ($c) => [ + 'icon' => '๐ฅ', + 'title' => $c->name, + 'subtitle' => collect([$c->company, $c->email])->filter()->implode(' ยท '), + 'url' => route('clients.show', $c), + ])->values()->all(), + ]; + } + + // โโ Events โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + $events = Event::with('client') + ->where('title', 'LIKE', $like) + ->orWhere('description', 'LIKE', $like) + ->orWhere('location', 'LIKE', $like) + ->limit($limit)->get(); + + if ($events->isNotEmpty()) { + $groups[] = [ + 'type' => 'events', + 'label' => '๐ Events', + 'items' => $events->map(fn ($e) => [ + 'icon' => '๐ ', + 'title' => $e->title, + 'subtitle' => $e->start_time->format('d M Y') . ($e->client ? ' ยท ' . $e->client->name : ''), + 'url' => route('schedule.index'), + ])->values()->all(), + ]; + } + + // โโ Invoices โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + $invoices = Invoice::with('client') + ->where('invoice_number', 'LIKE', $like) + ->orWhere('title', 'LIKE', $like) + ->limit($limit)->get(); + + if ($invoices->isNotEmpty()) { + $groups[] = [ + 'type' => 'invoices', + 'label' => '๐งพ Invoices', + 'items' => $invoices->map(fn ($i) => [ + 'icon' => '๐งพ', + 'title' => $i->invoice_number . ($i->title ? ' โ ' . $i->title : ''), + 'subtitle' => ($i->client?->name ?? '') . ' ยท ' . ($i->formatted_total ?? ''), + 'url' => route('invoices.show', $i), + ])->values()->all(), + ]; + } + + // โโ Content Briefs โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + $content = ContentBrief::with('client') + ->where('title', 'LIKE', $like) + ->orWhere('description', 'LIKE', $like) + ->orWhere('platform', 'LIKE', $like) + ->limit($limit)->get(); + + if ($content->isNotEmpty()) { + $groups[] = [ + 'type' => 'content', + 'label' => '๐ฌ Content', + 'items' => $content->map(fn ($c) => [ + 'icon' => $c->platform_icon ?? '๐ฌ', + 'title' => $c->title, + 'subtitle' => ucfirst($c->platform) . ($c->client ? ' ยท ' . $c->client->name : ''), + 'url' => route('content.show', $c), + ])->values()->all(), + ]; + } + + // โโ Rate Cards โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + $rateCards = RateCard::where('name', 'LIKE', $like) + ->orWhere('description', 'LIKE', $like) + ->limit($limit)->get(); + + if ($rateCards->isNotEmpty()) { + $groups[] = [ + 'type' => 'rate-cards', + 'label' => '๐ฐ Rate Cards', + 'items' => $rateCards->map(fn ($r) => [ + 'icon' => '๐ฐ', + 'title' => $r->name, + 'subtitle' => $r->description ?? '', + 'url' => route('rate-cards.edit', $r), + ])->values()->all(), + ]; + } + + return $groups; + } +} diff --git a/app/Models/Client.php b/app/Models/Client.php index ad605f3..08cbfe3 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -7,6 +7,7 @@ namespace App\Models; use App\Enums\ClientStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -18,13 +19,20 @@ class Client extends Model 'name', 'email', 'phone', 'company', 'ig_handle', 'tiktok_handle', 'youtube_handle', 'address_line1', 'address_line2', 'city', 'state', 'postal_code', 'country', - 'status', 'notes', 'avatar', + 'status', 'category_id', 'notes', 'avatar', ]; protected $casts = [ 'status' => ClientStatus::class, ]; + // โโ Relationships โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + + public function category(): BelongsTo + { + return $this->belongsTo(ClientCategory::class, 'category_id'); + } + public function events(): HasMany { return $this->hasMany(Event::class); @@ -45,6 +53,8 @@ class Client extends Model return $this->hasMany(MediaFile::class); } + // โโ Accessors โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + public function getFullAddressAttribute(): string { return collect([ diff --git a/app/Models/ClientCategory.php b/app/Models/ClientCategory.php new file mode 100644 index 0000000..6752151 --- /dev/null +++ b/app/Models/ClientCategory.php @@ -0,0 +1,74 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + + // โโ Relationships โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + + public function parent(): BelongsTo + { + return $this->belongsTo(ClientCategory::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(ClientCategory::class, 'parent_id')->orderBy('sort_order'); + } + + public function clients(): HasMany + { + return $this->hasMany(Client::class, 'category_id'); + } + + // โโ Scopes โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + + public function scopeRoots($query) + { + return $query->whereNull('parent_id')->orderBy('sort_order'); + } + + public function scopeTopLevel($query) + { + return $query->whereNull('parent_id'); + } + + // โโ Accessors โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + + public function getFullNameAttribute(): string + { + return $this->parent + ? $this->parent->name . ' โบ ' . $this->name + : $this->name; + } + + // โโ Events โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + + protected static function boot(): void + { + parent::boot(); + + static::creating(function (ClientCategory $cat): void { + if (empty($cat->slug)) { + $cat->slug = Str::slug($cat->name); + } + }); + } +} diff --git a/app/Models/NotificationSetting.php b/app/Models/NotificationSetting.php new file mode 100644 index 0000000..32298dd --- /dev/null +++ b/app/Models/NotificationSetting.php @@ -0,0 +1,53 @@ + 'array', + 'triggers' => 'array', + 'is_enabled' => 'boolean', + 'notify_before_hours' => 'integer', + ]; + + // โโ Channel icons/labels โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + + public static array $channelMeta = [ + 'email' => ['icon' => 'โ๏ธ', 'label' => 'Email', 'color' => '#AEC6CF'], + 'telegram' => ['icon' => 'โ๏ธ', 'label' => 'Telegram', 'color' => '#E8D5F5'], + 'whatsapp' => ['icon' => '๐ฌ', 'label' => 'WhatsApp', 'color' => '#B5EAD7'], + ]; + + public function getChannelIconAttribute(): string + { + return static::$channelMeta[$this->channel]['icon'] ?? '๐'; + } + + public function getChannelLabelAttribute(): string + { + return static::$channelMeta[$this->channel]['label'] ?? ucfirst($this->channel); + } + + // โโ Trigger helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + + public static array $triggerMeta = [ + 'upcoming_event' => ['icon' => '๐ ', 'label' => 'Upcoming Events'], + 'invoice_due' => ['icon' => '๐งพ', 'label' => 'Invoice Due Date'], + 'content_due' => ['icon' => '๐ฌ', 'label' => 'Content Target Date'], + ]; + + public function hasTrigger(string $trigger): bool + { + return in_array($trigger, $this->triggers ?? []); + } +} diff --git a/app/Services/NotificationDispatcher.php b/app/Services/NotificationDispatcher.php new file mode 100644 index 0000000..df48161 --- /dev/null +++ b/app/Services/NotificationDispatcher.php @@ -0,0 +1,113 @@ +get(); + + foreach ($settings as $setting) { + try { + $this->dispatchForSetting($setting); + } catch (\Throwable $e) { + Log::error("Notification dispatch failed for [{$setting->name}]: " . $e->getMessage()); + } + } + } + + public function dispatchForSetting(NotificationSetting $setting): void + { + $triggers = $setting->triggers ?? []; + $hours = $setting->notify_before_hours; + + $messages = collect(); + + if (in_array('upcoming_event', $triggers)) { + $events = Event::with('client') + ->where('start_time', '>=', now()) + ->where('start_time', '<=', now()->addHours($hours)) + ->whereNotIn('status', ['completed', 'cancelled']) + ->get(); + + foreach ($events as $event) { + $messages->push( + "๐ *Upcoming Event*\n" + . "*{$event->title}*\n" + . "โฐ " . $event->start_time->format('d M Y, H:i') . "\n" + . ($event->client ? "๐ค {$event->client->name}\n" : '') + . ($event->location ? "๐ {$event->location}" : '') + ); + } + } + + if (in_array('invoice_due', $triggers)) { + $invoices = Invoice::with('client') + ->whereBetween('due_date', [now(), now()->addHours($hours)]) + ->whereNotIn('status', ['paid', 'cancelled']) + ->get(); + + foreach ($invoices as $inv) { + $messages->push( + "๐งพ *Invoice Due Soon*\n" + . "*{$inv->invoice_number}*" + . ($inv->title ? " โ {$inv->title}" : '') . "\n" + . "๐ Due: " . $inv->due_date->format('d M Y') . "\n" + . ($inv->client ? "๐ค {$inv->client->name}\n" : '') + . ($inv->formatted_total ?? '') + ); + } + } + + if (in_array('content_due', $triggers)) { + $content = ContentBrief::with('client') + ->whereBetween('target_date', [now(), now()->addHours($hours)]) + ->whereNotIn('status', ['posted', 'archived']) + ->get(); + + foreach ($content as $c) { + $messages->push( + "๐ฌ *Content Target*\n" + . "*{$c->title}*\n" + . "๐ " . $c->target_date->format('d M Y') . "\n" + . ucfirst($c->platform) + . ($c->client ? " ยท {$c->client->name}" : '') + ); + } + } + + if ($messages->isEmpty()) return; + + $notifier = $this->resolveNotifier($setting); + $notifier->sendBatch($setting, $messages->all()); + } + + /** Send a simple test message */ + public function sendTest(NotificationSetting $setting): void + { + $this->resolveNotifier($setting)->sendTest($setting); + } + + private function resolveNotifier(NotificationSetting $setting): EmailNotifier|TelegramNotifier|WahaNotifier + { + return match ($setting->channel) { + 'email' => new EmailNotifier, + 'telegram' => new TelegramNotifier, + 'whatsapp' => new WahaNotifier, + default => throw new \InvalidArgumentException("Unknown channel: {$setting->channel}"), + }; + } +} diff --git a/app/Services/Notifications/EmailNotifier.php b/app/Services/Notifications/EmailNotifier.php new file mode 100644 index 0000000..e11cc80 --- /dev/null +++ b/app/Services/Notifications/EmailNotifier.php @@ -0,0 +1,43 @@ +config['to_email'] ?? null; + + if (! $toEmail) { + throw new \RuntimeException('Email to_email address is required.'); + } + + $combined = implode("\n\n---\n\n", $messages); + + Mail::raw($combined, function ($mail) use ($toEmail, $setting): void { + $mail->to($toEmail) + ->subject("๐ฑ KOL Manager โ {$setting->name} Notifications"); + }); + } + + public function sendTest(NotificationSetting $setting): void + { + $toEmail = $setting->config['to_email'] ?? null; + + if (! $toEmail) { + throw new \RuntimeException('Email to_email address is required.'); + } + + Mail::raw( + "๐ฑ KOL Manager โ Test Notification\n\nYour Email channel is configured correctly! ๐", + function ($mail) use ($toEmail): void { + $mail->to($toEmail)->subject('๐ฑ KOL Manager โ Test Notification'); + } + ); + } +} diff --git a/app/Services/Notifications/TelegramNotifier.php b/app/Services/Notifications/TelegramNotifier.php new file mode 100644 index 0000000..7a02ef6 --- /dev/null +++ b/app/Services/Notifications/TelegramNotifier.php @@ -0,0 +1,50 @@ +send($setting, $message); + } + } + + public function sendTest(NotificationSetting $setting): void + { + $this->send( + $setting, + "๐ฑ *KOL Manager โ Test Notification*\n\nYour Telegram channel is configured correctly! ๐" + ); + } + + private function send(NotificationSetting $setting, string $text): void + { + $config = $setting->config ?? []; + $botToken = $config['bot_token'] ?? null; + $chatId = $config['chat_id'] ?? null; + + if (! $botToken || ! $chatId) { + throw new \RuntimeException('Telegram bot_token and chat_id are required.'); + } + + $response = Http::timeout(10)->post( + "https://api.telegram.org/bot{$botToken}/sendMessage", + [ + 'chat_id' => $chatId, + 'text' => $text, + 'parse_mode' => 'Markdown', + ] + ); + + if (! $response->successful()) { + throw new \RuntimeException('Telegram API error: ' . $response->body()); + } + } +} diff --git a/app/Services/Notifications/WahaNotifier.php b/app/Services/Notifications/WahaNotifier.php new file mode 100644 index 0000000..3d08b2e --- /dev/null +++ b/app/Services/Notifications/WahaNotifier.php @@ -0,0 +1,55 @@ +send($setting, $message); + } + } + + public function sendTest(NotificationSetting $setting): void + { + $this->send( + $setting, + "๐ฑ *KOL Manager โ Test Notification*\n\nYour WhatsApp channel is configured correctly! ๐" + ); + } + + 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; + + if (! $url || ! $phone) { + throw new \RuntimeException('WAHA waha_url and phone are required.'); + } + + // Normalise phone: strip non-digits, ensure no leading + + $chatId = preg_replace('/[^0-9]/', '', $phone) . '@c.us'; + + $response = Http::timeout(15)->post("{$url}/api/sendText", [ + 'session' => $session, + 'chatId' => $chatId, + 'text' => $text, + ]); + + if (! $response->successful()) { + throw new \RuntimeException('WAHA API error: ' . $response->body()); + } + } +} diff --git a/database/migrations/2024_01_01_000080_create_client_categories_table.php b/database/migrations/2024_01_01_000080_create_client_categories_table.php new file mode 100644 index 0000000..92f8d0a --- /dev/null +++ b/database/migrations/2024_01_01_000080_create_client_categories_table.php @@ -0,0 +1,50 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->unsignedBigInteger('parent_id')->nullable(); + $table->string('icon')->default('๐ท๏ธ'); + $table->string('color')->default('#FFB7C5'); + $table->string('description')->nullable(); + $table->boolean('is_system')->default(false); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + + $table->foreign('parent_id') + ->references('id') + ->on('client_categories') + ->onDelete('set null'); + }); + + // Add category_id to clients table + Schema::table('clients', function (Blueprint $table): void { + $table->unsignedBigInteger('category_id')->nullable()->after('status'); + $table->foreign('category_id') + ->references('id') + ->on('client_categories') + ->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::table('clients', function (Blueprint $table): void { + $table->dropForeign(['category_id']); + $table->dropColumn('category_id'); + }); + + Schema::dropIfExists('client_categories'); + } +}; diff --git a/database/migrations/2024_01_01_000090_create_notification_settings_table.php b/database/migrations/2024_01_01_000090_create_notification_settings_table.php new file mode 100644 index 0000000..c2f85d3 --- /dev/null +++ b/database/migrations/2024_01_01_000090_create_notification_settings_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('name'); // e.g. "My Telegram Bot" + $table->string('channel'); // email | telegram | whatsapp + $table->json('config'); // channel-specific credentials + $table->json('triggers')->nullable(); // array: upcoming_event, invoice_due, content_due + $table->boolean('is_enabled')->default(true); + $table->integer('notify_before_hours')->default(24); // hours before event to notify + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notification_settings'); + } +}; diff --git a/database/seeders/ClientCategorySeeder.php b/database/seeders/ClientCategorySeeder.php new file mode 100644 index 0000000..7ac274a --- /dev/null +++ b/database/seeders/ClientCategorySeeder.php @@ -0,0 +1,89 @@ + 'Food & Beverages', 'icon' => '๐', 'color' => '#FFD5B8', 'children' => [ + ['name' => 'Food', 'icon' => '๐ฑ', 'color' => '#FFD5B8'], + ['name' => 'Beverages', 'icon' => '๐ฅค', 'color' => '#FFD5B8'], + ['name' => 'Restaurant & Cafรฉ', 'icon' => 'โ', 'color' => '#FFD5B8'], + ['name' => 'Snacks & Treats', 'icon' => '๐ช', 'color' => '#FFD5B8'], + ]], + ['name' => 'Fashion', 'icon' => '๐', 'color' => '#FFB7C5', 'children' => [ + ['name' => 'Clothing', 'icon' => '๐', 'color' => '#FFB7C5'], + ['name' => 'Accessories', 'icon' => '๐', 'color' => '#FFB7C5'], + ['name' => 'Shoes & Footwear', 'icon' => '๐ ', 'color' => '#FFB7C5'], + ['name' => 'Bags & Luggage', 'icon' => '๐', 'color' => '#FFB7C5'], + ]], + ['name' => 'Beauty & Skincare', 'icon' => '๐', 'color' => '#E8D5F5', 'children' => [ + ['name' => 'Skincare', 'icon' => '๐งด', 'color' => '#E8D5F5'], + ['name' => 'Makeup', 'icon' => '๐', 'color' => '#E8D5F5'], + ['name' => 'Hair Care', 'icon' => '๐', 'color' => '#E8D5F5'], + ['name' => 'Fragrance', 'icon' => '๐ธ', 'color' => '#E8D5F5'], + ]], + ['name' => 'Technology', 'icon' => '๐ป', 'color' => '#AEC6CF', 'children' => [ + ['name' => 'Consumer Electronics', 'icon' => '๐ฑ', 'color' => '#AEC6CF'], + ['name' => 'Apps & Software', 'icon' => '๐ฅ๏ธ', 'color' => '#AEC6CF'], + ['name' => 'Gaming', 'icon' => '๐ฎ', 'color' => '#AEC6CF'], + ['name' => 'Smart Home', 'icon' => '๐ ', 'color' => '#AEC6CF'], + ]], + ['name' => 'Health & Wellness', 'icon' => '๐ช', 'color' => '#B5EAD7', 'children' => [ + ['name' => 'Fitness', 'icon' => '๐๏ธ', 'color' => '#B5EAD7'], + ['name' => 'Supplements', 'icon' => '๐', 'color' => '#B5EAD7'], + ['name' => 'Mental Health', 'icon' => '๐ง', 'color' => '#B5EAD7'], + ]], + ['name' => 'Lifestyle', 'icon' => '๐ฟ', 'color' => '#FDFD96', 'children' => [ + ['name' => 'Travel', 'icon' => 'โ๏ธ', 'color' => '#FDFD96'], + ['name' => 'Home & Living', 'icon' => '๐๏ธ', 'color' => '#FDFD96'], + ['name' => 'Pets', 'icon' => '๐พ', 'color' => '#FDFD96'], + ['name' => 'Parenting', 'icon' => '๐ถ', 'color' => '#FDFD96'], + ]], + ['name' => 'Entertainment', 'icon' => '๐ฌ', 'color' => '#AEC6CF', 'children' => [ + ['name' => 'Movies & TV', 'icon' => '๐ฅ', 'color' => '#AEC6CF'], + ['name' => 'Music', 'icon' => '๐ต', 'color' => '#AEC6CF'], + ['name' => 'Sports', 'icon' => 'โฝ', 'color' => '#AEC6CF'], + ['name' => 'Gaming & Esports', 'icon' => '๐น๏ธ', 'color' => '#AEC6CF'], + ]], + ['name' => 'Finance', 'icon' => '๐ฐ', 'color' => '#FDFD96', 'children' => [ + ['name' => 'Banking', 'icon' => '๐ฆ', 'color' => '#FDFD96'], + ['name' => 'Insurance', 'icon' => '๐ก๏ธ', 'color' => '#FDFD96'], + ['name' => 'Investment', 'icon' => '๐', 'color' => '#FDFD96'], + ]], + ['name' => 'Education', 'icon' => '๐', 'color' => '#B5EAD7', 'children' => [ + ['name' => 'Online Courses', 'icon' => '๐', 'color' => '#B5EAD7'], + ['name' => 'Kids Education', 'icon' => '๐ง', 'color' => '#B5EAD7'], + ]], + ['name' => 'Other', 'icon' => '๐ท๏ธ', 'color' => '#E5E7EB', 'children' => []], + ]; + + foreach ($categories as $i => $data) { + $parent = ClientCategory::create([ + 'name' => $data['name'], + 'icon' => $data['icon'], + 'color' => $data['color'], + 'is_system' => true, + 'sort_order' => $i, + ]); + + foreach ($data['children'] as $j => $child) { + ClientCategory::create([ + 'name' => $child['name'], + 'icon' => $child['icon'], + 'color' => $child['color'], + 'parent_id' => $parent->id, + 'is_system' => true, + 'sort_order' => $j, + ]); + } + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..ea142ca 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -21,5 +21,9 @@ class DatabaseSeeder extends Seeder 'name' => 'Test User', 'email' => 'test@example.com', ]); + + $this->call([ + ClientCategorySeeder::class, + ]); } } diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..be3a083 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "KOL Manager", + "short_name": "KOL", + "description": "Your Purr-fect Influencer Management Assistant", + "start_url": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#FFF8F0", + "theme_color": "#FFB7C5", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["business", "productivity"], + "screenshots": [] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..e1ef72c --- /dev/null +++ b/public/sw.js @@ -0,0 +1,61 @@ +// KOL Manager Service Worker +// Provides basic offline support and caches static assets + +const CACHE_NAME = 'kol-manager-v1'; +const STATIC_ASSETS = [ + '/', + '/manifest.json', +]; + +// Install โ pre-cache shell +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +// Activate โ clean up old caches +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +// Fetch โ network-first for HTML/API, cache-first for static assets +self.addEventListener('fetch', event => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET and cross-origin requests + if (request.method !== 'GET' || url.origin !== location.origin) return; + + // Cache-first for Vite build assets (hashed filenames) + if (url.pathname.startsWith('/build/')) { + event.respondWith( + caches.match(request).then(cached => cached || fetch(request).then(response => { + const clone = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(request, clone)); + return response; + })) + ); + return; + } + + // Network-first for everything else (app routes) + event.respondWith( + fetch(request) + .then(response => { + // Cache successful HTML responses + if (response.ok && request.headers.get('Accept')?.includes('text/html')) { + const clone = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(request, clone)); + } + return response; + }) + .catch(() => caches.match(request)) + ); +}); diff --git a/resources/js/app.js b/resources/js/app.js index fd85d7a..11f31b0 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -6,39 +6,26 @@ Alpine.plugin(sort); window.Alpine = Alpine; Alpine.start(); -// Meow sound toggle (stored in localStorage) +// โโ PWA Service Worker โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => {/* silently fail */}); + }); +} + +// โโ KolManager helpers (toast notifications only โ no meow) โโ window.KolManager = { - meowEnabled: localStorage.getItem('meow') !== 'false', - - toggleMeow() { - this.meowEnabled = !this.meowEnabled; - localStorage.setItem('meow', this.meowEnabled); - }, - - playMeow() { - if (!this.meowEnabled) return; - const ctx = new (window.AudioContext || window.webkitAudioContext)(); - const o = ctx.createOscillator(); - const g = ctx.createGain(); - o.connect(g); - g.connect(ctx.destination); - o.type = 'sine'; - o.frequency.setValueAtTime(880, ctx.currentTime); - o.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.05); - o.frequency.exponentialRampToValueAtTime(900, ctx.currentTime + 0.15); - g.gain.setValueAtTime(0.3, ctx.currentTime); - g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3); - o.start(ctx.currentTime); - o.stop(ctx.currentTime + 0.3); - }, - notify(msg) { - this.playMeow(); const el = document.createElement('div'); - el.className = 'fixed top-4 right-4 z-50 cat-card px-5 py-3 flex items-center gap-2 shadow-lg border border-pink-200 animate-bounce'; + el.className = 'fixed top-4 right-4 z-50 cat-card px-5 py-3 flex items-center gap-2 shadow-lg border border-pink-200'; + el.style.cssText = 'animation: slideIn .3s ease'; el.innerHTML = `๐ฑ${msg}`; document.body.appendChild(el); - setTimeout(() => el.remove(), 3500); + setTimeout(() => { + el.style.opacity = '0'; + el.style.transition = 'opacity .3s'; + setTimeout(() => el.remove(), 300); + }, 3200); }, }; @@ -46,7 +33,6 @@ window.KolManager = { document.addEventListener('DOMContentLoaded', () => { const flash = document.getElementById('flash-message'); if (flash) { - window.KolManager.playMeow(); setTimeout(() => { flash.style.opacity = '0'; flash.style.transform = 'translateY(-10px)'; diff --git a/resources/views/clients/create.blade.php b/resources/views/clients/create.blade.php index 18ab91f..fbc4a67 100644 --- a/resources/views/clients/create.blade.php +++ b/resources/views/clients/create.blade.php @@ -42,6 +42,22 @@ @endforeach +
No clients yet!
- Add your first client +No clients found!
+ @if(request('category') || request('status')) + Clear filters + @else + Add your first client + @endifStart typing to search across everything
+Clients, events, invoices, content briefs, rate cards
+No results for "{{ e($q) }}"
+Try a different keyword
+How notifications work
+Add one or more channels below. Notifications fire automatically every hour via the scheduler.
+To enable the scheduler on your server: add * * * * * php /path/to/artisan schedule:run to crontab.
No notification channels yet
+Add one below to get started
+