From 0397959f6042ec2418a9dd9fef6ccd1442549f67 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Tue, 25 Aug 2026 13:21:11 +0700 Subject: [PATCH] feat: implement notification system with CRUD operations and UI integration --- .../Controllers/NotificationController.php | 47 ++++ app/Http/Middleware/HandleInertiaRequests.php | 8 + app/Models/Notification.php | 26 +++ app/Models/User.php | 6 + app/Services/NotificationService.php | 50 +++++ ...8_25_000004_create_notifications_table.php | 25 +++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/NotificationSeeder.php | 31 +++ .../js/components/app-sidebar-header.tsx | 4 +- resources/js/components/notification-bell.tsx | 207 ++++++++++++++++++ resources/js/hooks/use-initials.tsx | 1 - resources/js/types/global.d.ts | 1 + resources/js/types/notification.ts | 16 ++ routes/web.php | 8 + 14 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Models/Notification.php create mode 100644 app/Services/NotificationService.php create mode 100644 database/migrations/2026_08_25_000004_create_notifications_table.php create mode 100644 database/seeders/NotificationSeeder.php create mode 100644 resources/js/components/notification-bell.tsx create mode 100644 resources/js/types/notification.ts diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..e79978a --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,47 @@ +service->markAllAsRead($request->user()); + + return back(); + } + + public function destroyAll(Request $request): RedirectResponse + { + $this->service->deleteAll($request->user()); + + return back(); + } + + public function markRead(Request $request, Notification $notification): RedirectResponse + { + abort_unless($notification->user_id === $request->user()->id, 403); + + $this->service->markAsRead($notification); + + return back(); + } + + public function destroy(Request $request, Notification $notification): RedirectResponse + { + abort_unless($notification->user_id === $request->user()->id, 403); + + $this->service->delete($notification); + + return back(); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f4cc770..9118ed5 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,7 +2,9 @@ namespace App\Http\Middleware; +use App\Services\NotificationService; use Illuminate\Http\Request; +use Inertia\Inertia; use Inertia\Middleware; class HandleInertiaRequests extends Middleware @@ -42,6 +44,12 @@ public function share(Request $request): array 'user' => $request->user(), ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', + 'unreadNotificationsCount' => fn () => $request->user() + ? app(NotificationService::class)->unreadCount($request->user()) + : 0, + 'notifications' => Inertia::merge(fn () => $request->user() + ? app(NotificationService::class)->paginated($request->user()) + : null)->append('data', 'id'), ]; } } diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 0000000..23f4c25 --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,26 @@ + 'boolean', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f95b5ea..9d023c5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -49,4 +50,9 @@ public function lecturer(): HasOne { return $this->hasOne(Lecturer::class); } + + public function notifications(): HasMany + { + return $this->hasMany(Notification::class); + } } diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php new file mode 100644 index 0000000..43e6016 --- /dev/null +++ b/app/Services/NotificationService.php @@ -0,0 +1,50 @@ +where('user_id', $user->id) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginate($perPage); + } + + public function unreadCount(User $user): int + { + return Notification::query() + ->where('user_id', $user->id) + ->where('is_read', false) + ->count(); + } + + public function markAsRead(Notification $notification): void + { + $notification->update(['is_read' => true]); + } + + public function markAllAsRead(User $user): void + { + Notification::query() + ->where('user_id', $user->id) + ->where('is_read', false) + ->update(['is_read' => true]); + } + + public function delete(Notification $notification): bool + { + return $notification->delete(); + } + + public function deleteAll(User $user): void + { + Notification::query()->where('user_id', $user->id)->delete(); + } +} diff --git a/database/migrations/2026_08_25_000004_create_notifications_table.php b/database/migrations/2026_08_25_000004_create_notifications_table.php new file mode 100644 index 0000000..f381484 --- /dev/null +++ b/database/migrations/2026_08_25_000004_create_notifications_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('title', 150)->nullable(); + $table->text('content')->nullable(); + $table->boolean('is_read')->nullable()->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e15ef5b..68da324 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -30,6 +30,7 @@ public function run(): void TuitionPaymentSeeder::class, AnnouncementSeeder::class, LetterRequestSeeder::class, + NotificationSeeder::class, ]); } } diff --git a/database/seeders/NotificationSeeder.php b/database/seeders/NotificationSeeder.php new file mode 100644 index 0000000..0da9df3 --- /dev/null +++ b/database/seeders/NotificationSeeder.php @@ -0,0 +1,31 @@ + 1, + 'title' => 'Tugas baru diunggah', + 'content' => 'Tugas "Membuat Landing Page" telah tersedia di kelas SI-5A', + 'is_read' => false, + 'created_at' => '2026-08-05 09:05:00', + 'updated_at' => '2026-08-05 09:05:00', + ], + [ + 'user_id' => 2, + 'title' => 'Pembayaran UKT diterima sebagian', + 'content' => 'Pembayaran cicilan tahap 1 sebesar Rp1.500.000 telah tercatat', + 'is_read' => true, + 'created_at' => '2026-08-04 11:05:00', + 'updated_at' => '2026-08-04 12:00:00', + ], + ]); + } +} diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx index ad4ac74..f663319 100644 --- a/resources/js/components/app-sidebar-header.tsx +++ b/resources/js/components/app-sidebar-header.tsx @@ -1,5 +1,6 @@ import { Breadcrumbs } from '@/components/breadcrumbs'; import { NavUser } from '@/components/nav-user'; +import { NotificationBell } from '@/components/notification-bell'; import { SidebarTrigger } from '@/components/ui/sidebar'; import type { BreadcrumbItem as BreadcrumbItemType } from '@/types'; @@ -14,7 +15,8 @@ export function AppSidebarHeader({ -
+
+
diff --git a/resources/js/components/notification-bell.tsx b/resources/js/components/notification-bell.tsx new file mode 100644 index 0000000..f21f88b --- /dev/null +++ b/resources/js/components/notification-bell.tsx @@ -0,0 +1,207 @@ +import { Combobox as ComboboxPrimitive } from '@base-ui/react'; +import { router, usePage, WhenVisible } from '@inertiajs/react'; +import { format } from 'date-fns'; +import { Bell, CheckCheck, Loader2, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { Button } from '@/components/ui/button'; +import { Combobox, ComboboxContent } from '@/components/ui/combobox'; +import { cn } from '@/lib/utils'; +import { destroy, markAllRead, clearAll, read } from '@/routes/notifications'; +import type { Notification, NotificationPage } from '@/types/notification'; + +export function NotificationBell() { + const { props } = usePage<{ notifications?: NotificationPage }>(); + const unreadCount = props.unreadNotificationsCount ?? 0; + const notifications = props.notifications ?? { + data: [], + current_page: 1, + last_page: 1, + per_page: 15, + total: 0, + }; + + const [open, setOpen] = useState(false); + const [clearAllOpen, setClearAllOpen] = useState(false); + + const hasMore = notifications.current_page < notifications.last_page; + const hasNotifications = notifications.data.length > 0; + const hasUnread = notifications.data.some((n) => !n.is_read); + + function handleMarkAllRead() { + router.patch(markAllRead().url, {}, { preserveScroll: true }); + } + + function handleClearAll() { + router.delete(clearAll().url, { + preserveScroll: true, + onSuccess: () => setClearAllOpen(false), + }); + } + + function handleMarkRead(notification: Notification) { + router.patch(read(notification.id).url, {}, { preserveScroll: true }); + } + + function handleDelete(notification: Notification) { + router.delete(destroy(notification.id).url, { preserveScroll: true }); + } + + return ( + <> + + + } + > + + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + Notifikasi + + + +
+ Notifikasi +
+ + +
+
+ +
+ {hasNotifications ? ( +
+ {notifications.data.map((notification) => ( +
+
+ {!notification.is_read && ( + + )} +
+

+ {notification.title ?? '-'} +

+ {notification.content && ( +

+ {notification.content} +

+ )} +

+ {format( + new Date( + notification.created_at, + ), + 'd MMM yyyy, HH:mm', + )} +

+
+
+
+ {!notification.is_read && ( + + )} + +
+
+ ))} + + {hasMore && ( + + +
+ } + > +
+ +
+ + )} +
+ ) : ( +
+ Belum ada notifikasi. +
+ )} +
+ + + + + + ); +} diff --git a/resources/js/hooks/use-initials.tsx b/resources/js/hooks/use-initials.tsx index 40685e2..407309f 100644 --- a/resources/js/hooks/use-initials.tsx +++ b/resources/js/hooks/use-initials.tsx @@ -8,7 +8,6 @@ function getInitial(name: string): string { export function useInitials(): GetInitialsFn { return useCallback((fullName: string): string => { - console.log(fullName) const names = fullName.trim().split(/\s+/u).filter(Boolean); if (names.length === 0) { diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index f3f0d6b..20a529b 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -13,6 +13,7 @@ declare module '@inertiajs/core' { name: string; auth: Auth; sidebarOpen: boolean; + unreadNotificationsCount: number; [key: string]: unknown; }; } diff --git a/resources/js/types/notification.ts b/resources/js/types/notification.ts new file mode 100644 index 0000000..d5dcf48 --- /dev/null +++ b/resources/js/types/notification.ts @@ -0,0 +1,16 @@ +export type Notification = { + id: number; + title: string | null; + content: string | null; + is_read: boolean; + created_at: string; + updated_at: string; +}; + +export type NotificationPage = { + data: Notification[]; + current_page: number; + last_page: number; + per_page: number; + total: number; +}; diff --git a/routes/web.php b/routes/web.php index 04a46f3..c82a09e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,11 +1,19 @@ name('home'); Route::middleware(['auth', 'verified'])->group(function () { Route::inertia('dashboard', 'dashboard')->name('dashboard'); + + Route::prefix('notifications')->name('notifications.')->group(function () { + Route::patch('mark-all-read', [NotificationController::class, 'markAllRead'])->name('mark-all-read'); + Route::delete('clear-all', [NotificationController::class, 'destroyAll'])->name('clear-all'); + Route::patch('{notification}/read', [NotificationController::class, 'markRead'])->name('read'); + Route::delete('{notification}', [NotificationController::class, 'destroy'])->name('destroy'); + }); }); require __DIR__.'/settings.php'; -- 2.45.2