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 +
+ + +
diff --git a/resources/views/clients/edit.blade.php b/resources/views/clients/edit.blade.php index 8a9c9e8..3215db3 100644 --- a/resources/views/clients/edit.blade.php +++ b/resources/views/clients/edit.blade.php @@ -37,6 +37,22 @@ @endforeach +
+ + +
diff --git a/resources/views/clients/index.blade.php b/resources/views/clients/index.blade.php index 286c183..f957cd4 100644 --- a/resources/views/clients/index.blade.php +++ b/resources/views/clients/index.blade.php @@ -5,33 +5,102 @@ @section('page-subtitle', 'Manage your brand partners and contacts') @section('content') -
-
- - ๐Ÿ” + +{{-- โ”€โ”€ Filters bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ --}} +
+
+ {{-- Local text filter --}} +
+ ๐Ÿ” + +
+ + {{-- Category filter --}} +
+ + + + + @if(request('category') || request('status')) + โœ• Clear + @endif +
+ + ๏ผ‹ Add Client +
+ + {{-- Category pills (top-level only) --}} +
+ + All + + @foreach($categories as $cat) + + {{ $cat->icon }} {{ $cat->name }} + + @endforeach
- ๏ผ‹ Add Client
+{{-- โ”€โ”€ Client grid โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ --}}
@forelse($clients as $client)
+ data-name="{{ strtolower($client->name) }} {{ strtolower($client->company ?? '') }}">
+ style="background: linear-gradient(135deg, {{ $client->category?->color ?? '#FFB7C5' }}, #E8D5F5);"> {{ $client->initials }}

{{ $client->name }}

- {{ $client->status->label() }} + + {{ $client->status->label() }} +
@if($client->company)
{{ $client->company }}
@endif + @if($client->category) +
+ {{ $client->category->icon }} {{ $client->category->name }} + @if($client->category->parent) + ยท + {{ $client->category->parent->name }} + @endif +
+ @endif
@@ -62,18 +131,28 @@
View - Edit -
+ + Edit + + @csrf @method('DELETE') - +
@empty
๐Ÿฑ -

No clients yet!

- Add your first client +

No clients found!

+ @if(request('category') || request('status')) + Clear filters + @else + Add your first client + @endif
@endforelse
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 6517b6a..8b49107 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -2,14 +2,20 @@ - + + + + + + + {{ config('app.name') }} โ€” @yield('title', 'Dashboard') @vite(['resources/css/app.css', 'resources/js/app.js']) - + {{-- โ”€โ”€ Backdrop (mobile only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ --}}
- {{-- Logo / Close trigger --}} + {{-- Logo --}} @@ -65,9 +67,6 @@ class="nav-item {{ request()->routeIs('schedule.*') ? 'active' : '' }}"> ๐Ÿ“… Schedule - @if($alertCount > 0) - {{ $alertCount }} - @endif ๐Ÿ“ Files - - {{-- Meow Toggle --}} -
- -
Toggle notification sounds
-
+
Settings
+ + + ๐Ÿ”” Notifications + + {{-- โ”€โ”€ Main wrapper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ --}} @@ -127,33 +124,91 @@
- {{-- โ”€โ”€ Mobile / Desktop Topbar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ --}} + {{-- โ”€โ”€ Topbar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ --}}
- {{-- Cat icon โ€” hamburger for mobile --}} + {{-- Hamburger (mobile) --}} -
+ {{-- Page title (hidden when search open) --}} +

@yield('page-title', 'Dashboard')

+ {{-- โ”€โ”€ Global Search bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ --}} + + + {{-- Right actions --}}
- @if($alertCount > 0) - - {{-- Mobile: just the badge --}} - - ๐Ÿ”” {{ $alertCount }} - - @endif + {{-- Search toggle --}} + + + {{-- Avatar --}}
KOL @@ -185,7 +240,47 @@
- @stack('scripts') + + diff --git a/resources/views/schedule/edit.blade.php b/resources/views/schedule/edit.blade.php new file mode 100644 index 0000000..127bb60 --- /dev/null +++ b/resources/views/schedule/edit.blade.php @@ -0,0 +1,175 @@ +@extends('layouts.app') + +@section('title', 'Edit Event') +@section('page-title', 'Edit Event ๐Ÿ“…') +@section('page-subtitle', $event->title) + +@section('content') +
+
+
+ @csrf @method('PUT') + + {{-- Hidden combined datetime fields --}} + + + + {{-- Title --}} +
+ + + @error('title')

{{ $message }}

@enderror +
+ +
+ {{-- Type --}} +
+ + +
+ + {{-- Status --}} +
+ + +
+ + {{-- Client --}} +
+ + +
+ + {{-- Start Date --}} +
+ + +
+ + {{-- Start Time --}} +
+ + +
+ + {{-- End Date --}} +
+ + +
+ + {{-- End Time --}} +
+ + +
+ + {{-- Location --}} +
+ + +
+
+ + {{-- Description --}} +
+ + +
+ + {{-- Color --}} +
+ +
+ @foreach(['#FFB7C5','#B5EAD7','#FDFD96','#E8D5F5','#FFD5B8','#AEC6CF'] as $hex) + + @endforeach +
+
+ + {{-- Priority checkbox --}} +
+ is_priority) ? 'checked' : '' }}> + +
+ + {{-- Notes --}} +
+ + +
+ +
+ + + Cancel + +
+
+
+
+ +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/search/index.blade.php b/resources/views/search/index.blade.php new file mode 100644 index 0000000..7a55da1 --- /dev/null +++ b/resources/views/search/index.blade.php @@ -0,0 +1,62 @@ +@extends('layouts.app') + +@section('title', 'Search') +@section('page-title', '๐Ÿ” Search Results') +@section('page-subtitle', $q ? "Results for \"" . e($q) . "\"" : 'Enter a search term') + +@section('content') + +
+
+ ๐Ÿ” + +
+
+ +@if(! $q) +
+ ๐Ÿ” +

Start typing to search across everything

+

Clients, events, invoices, content briefs, rate cards

+
+@elseif(empty($results)) +
+ ๐Ÿ˜ฟ +

No results for "{{ e($q) }}"

+

Try a different keyword

+
+@else +
+ @foreach($results as $group) +
+
+

{{ $group['label'] }}

+
+ +
+ @endforeach +
+@endif + +@endsection diff --git a/resources/views/settings/notifications.blade.php b/resources/views/settings/notifications.blade.php new file mode 100644 index 0000000..efa5289 --- /dev/null +++ b/resources/views/settings/notifications.blade.php @@ -0,0 +1,263 @@ +@extends('layouts.app') + +@section('title', 'Notification Settings') +@section('page-title', '๐Ÿ”” Notifications') +@section('page-subtitle', 'Configure Email, Telegram & WhatsApp alerts') + +@section('content') + +
+ + {{-- โ”€โ”€ How it works โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ --}} +
+ ๐Ÿ’ก +
+

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.

+
+
+ + {{-- โ”€โ”€ Existing channels โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ --}} + @if($settings->isNotEmpty()) +
+ @foreach($settings as $setting) +
+
+ {{ $setting->channelIcon }} +
+
+ {{ $setting->name }} + {{ $setting->channelLabel }} + @if(! $setting->is_enabled) + Disabled + @endif +
+
+ @foreach($setting->triggers ?? [] as $trigger) + {{ \App\Models\NotificationSetting::$triggerMeta[$trigger]['icon'] ?? '๐Ÿ””' }} + {{ \App\Models\NotificationSetting::$triggerMeta[$trigger]['label'] ?? $trigger }} + @endforeach + โฐ {{ $setting->notify_before_hours }}h before +
+
+
+ {{-- Test --}} +
+ @csrf + +
+ {{-- Toggle --}} +
+ @csrf + +
+ {{-- Edit --}} + + {{-- Delete --}} +
+ @csrf @method('DELETE') + +
+
+
+ + {{-- Inline edit form --}} +
+
+ @csrf @method('PUT') + +
+
+ + +
+
+ + +
+
+ + {{-- Triggers --}} +
+ +
+ @foreach(\App\Models\NotificationSetting::$triggerMeta as $key => $meta) + + @endforeach +
+
+ + {{-- Channel-specific config --}} + @if($setting->channel === 'email') +
+ + +
+ @elseif($setting->channel === 'telegram') +
+
+ + +
+
+ + +
+
+ @elseif($setting->channel === 'whatsapp') +
+
+ + +
+
+ + +
+
+ + +
+
+ @endif + + +
+
+
+ @endforeach +
+ @else +
+ ๐Ÿ”• +

No notification channels yet

+

Add one below to get started

+
+ @endif + + {{-- โ”€โ”€ Add new channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ --}} +
+

+ โž• Add Notification Channel +

+ +
+ @csrf + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {{-- Triggers --}} +
+ +
+ @foreach(\App\Models\NotificationSetting::$triggerMeta as $key => $meta) + + @endforeach +
+
+ + {{-- Email --}} +
+ + +
+ + {{-- Telegram --}} +
+
+ + +

Get from @BotFather

+
+
+ + +

Use @userinfobot to find your ID

+
+
+ + {{-- WhatsApp / WAHA --}} +
+
+ + +

Your self-hosted WAHA instance

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+@endsection diff --git a/routes/console.php b/routes/console.php index 3c9adf1..89a6e6e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,20 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Artisan::command('notifications:dispatch', function () { + /** @var NotificationDispatcher $dispatcher */ + $dispatcher = app(NotificationDispatcher::class); + $dispatcher->dispatchAll(); + $this->info('๐Ÿ”” Notifications dispatched.'); +})->purpose('Send all pending KOL Manager notifications'); + +// Run every hour automatically +Schedule::command('notifications:dispatch')->hourly(); diff --git a/routes/web.php b/routes/web.php index 0c200ce..33535db 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,13 +2,16 @@ declare(strict_types=1); +use App\Http\Controllers\ClientCategoryController; use App\Http\Controllers\ClientController; use App\Http\Controllers\ContentBriefController; use App\Http\Controllers\DashboardController; use App\Http\Controllers\EventController; use App\Http\Controllers\InvoiceController; use App\Http\Controllers\MediaFileController; +use App\Http\Controllers\NotificationSettingController; use App\Http\Controllers\RateCardController; +use App\Http\Controllers\SearchController; use App\Http\Controllers\SocialHubController; use Illuminate\Support\Facades\Route; @@ -17,10 +20,19 @@ Route::get('/', [DashboardController::class, 'index'])->name('dashboard'); // CRM โ€“ Clients Route::resource('clients', ClientController::class); +// Client Categories +Route::prefix('client-categories')->name('client-categories.')->group(function (): void { + Route::get('/', [ClientCategoryController::class, 'index'])->name('index'); + Route::post('/', [ClientCategoryController::class, 'store'])->name('store'); + Route::put('/{category}', [ClientCategoryController::class, 'update'])->name('update'); + Route::delete('/{category}', [ClientCategoryController::class, 'destroy'])->name('destroy'); +}); + // Schedule & Calendar Route::prefix('schedule')->name('schedule.')->group(function (): void { Route::get('/', [EventController::class, 'index'])->name('index'); Route::post('/', [EventController::class, 'store'])->name('store'); + Route::get('/{event}/edit', [EventController::class, 'edit'])->name('edit'); Route::put('/{event}', [EventController::class, 'update'])->name('update'); Route::patch('/{event}/dates', [EventController::class, 'updateDates'])->name('update-dates'); Route::delete('/{event}', [EventController::class, 'destroy'])->name('destroy'); @@ -60,3 +72,19 @@ Route::prefix('files')->name('files.')->group(function (): void { Route::get('/{mediaFile}/serve', [MediaFileController::class, 'serve'])->name('serve'); Route::delete('/{mediaFile}', [MediaFileController::class, 'destroy'])->name('destroy'); }); + +// Global Search +Route::prefix('search')->name('search.')->group(function (): void { + Route::get('/', [SearchController::class, 'index'])->name('index'); + Route::get('/quick', [SearchController::class, 'quick'])->name('quick'); +}); + +// Settings +Route::prefix('settings')->name('settings.')->group(function (): void { + Route::get('/notifications', [NotificationSettingController::class, 'index'])->name('notifications'); + Route::post('/notifications', [NotificationSettingController::class, 'store'])->name('notifications.store'); + Route::put('/notifications/{setting}', [NotificationSettingController::class, 'update'])->name('notifications.update'); + Route::delete('/notifications/{setting}', [NotificationSettingController::class, 'destroy'])->name('notifications.destroy'); + Route::post('/notifications/{setting}/test', [NotificationSettingController::class, 'test'])->name('notifications.test'); + Route::post('/notifications/{setting}/toggle', [NotificationSettingController::class, 'toggle'])->name('notifications.toggle'); +});