feat: Add notification services for Email, Telegram, and WhatsApp

- Implement EmailNotifier for sending batch and test notifications via email.
- Implement TelegramNotifier for sending batch and test notifications via Telegram.
- Implement WahaNotifier for sending batch and test notifications via WhatsApp using WAHA API.
- Create migration for client_categories table and update clients table to include category_id.
- Create migration for notification_settings table to manage notification configurations.
- Add ClientCategorySeeder to populate default client categories.
- Create manifest.json for PWA support.
- Implement service worker (sw.js) for caching static assets and offline support.
- Add edit event view for modifying scheduled events.
- Create search results view for displaying search queries.
- Develop notification settings view for configuring notification channels and triggers.
This commit is contained in:
I-Fun
2026-04-01 12:46:58 +07:00
parent 1126877ef5
commit d3c35ad730
29 changed files with 1849 additions and 96 deletions

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\ClientCategory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ClientCategoryController extends Controller
{
public function index()
{
// Managed inline from the clients page; redirect there
return redirect()->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.');
}
}

View File

@@ -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',
]);

View File

@@ -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([

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\NotificationSetting;
use App\Services\NotificationDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationSettingController extends Controller
{
public function index(): View
{
$settings = NotificationSetting::orderBy('channel')->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());
}
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Client;
use App\Models\ContentBrief;
use App\Models\Event;
use App\Models\Invoice;
use App\Models\RateCard;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SearchController extends Controller
{
public function index(Request $request): View
{
$q = trim($request->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;
}
}