Merge pull request 'feat: implement notification system with CRUD operations and UI integration' (#30) from feat/implement-notification-system into dev

Reviewed-on: #30
This commit is contained in:
pangestu 2026-08-25 14:28:59 +08:00
commit 4577d45e62
14 changed files with 429 additions and 2 deletions

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Models\Notification;
use App\Services\NotificationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function __construct(
private readonly NotificationService $service,
) {}
public function markAllRead(Request $request): RedirectResponse
{
$this->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();
}
}

View File

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

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
class Notification extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'is_read' => 'boolean',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Services;
use App\Models\Notification;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class NotificationService
{
public function paginated(User $user, int $perPage = 15): LengthAwarePaginator
{
return Notification::query()
->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();
}
}

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->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');
}
};

View File

@ -30,6 +30,7 @@ public function run(): void
TuitionPaymentSeeder::class,
AnnouncementSeeder::class,
LetterRequestSeeder::class,
NotificationSeeder::class,
]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Database\Seeders;
use App\Models\Notification;
use Illuminate\Database\Seeder;
class NotificationSeeder extends Seeder
{
public function run(): void
{
Notification::insert([
[
'user_id' => 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',
],
]);
}
}

View File

@ -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({
<SidebarTrigger className="-ml-1" />
<Breadcrumbs breadcrumbs={breadcrumbs} />
</div>
<div className="ml-auto">
<div className="ml-auto flex items-center gap-2">
<NotificationBell />
<NavUser />
</div>
</header>

View File

@ -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 (
<>
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxPrimitive.Trigger
render={
<Button
variant="ghost"
size="icon"
className="relative"
/>
}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<span className="sr-only">Notifikasi</span>
</ComboboxPrimitive.Trigger>
<ComboboxContent
align="end"
className="w-96 max-w-[90vw] min-w-96 p-0"
>
<div className="flex items-center justify-between border-b p-3">
<span className="font-medium">Notifikasi</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
title="Tandai semua dibaca"
disabled={!hasUnread}
onClick={handleMarkAllRead}
>
<CheckCheck className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Hapus semua"
disabled={!hasNotifications}
onClick={() => setClearAllOpen(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{hasNotifications ? (
<div className="flex flex-col divide-y">
{notifications.data.map((notification) => (
<div
key={notification.id}
className={cn(
'flex items-start justify-between gap-2 p-3',
!notification.is_read &&
'bg-primary/5',
)}
>
<div className="flex items-start gap-2">
{!notification.is_read && (
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
)}
<div>
<p className="text-sm font-medium">
{notification.title ?? '-'}
</p>
{notification.content && (
<p className="mt-0.5 text-xs text-muted-foreground">
{notification.content}
</p>
)}
<p className="mt-1 text-xs text-muted-foreground">
{format(
new Date(
notification.created_at,
),
'd MMM yyyy, HH:mm',
)}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{!notification.is_read && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Tandai dibaca"
onClick={() =>
handleMarkRead(
notification,
)
}
>
<CheckCheck className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Hapus"
onClick={() =>
handleDelete(notification)
}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
))}
{hasMore && (
<WhenVisible
always
data="notifications"
params={{
data: {
page:
notifications.current_page +
1,
},
}}
fallback={
<div className="flex justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
}
>
<div className="flex justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</WhenVisible>
)}
</div>
) : (
<div className="flex h-24 items-center justify-center text-sm text-muted-foreground">
Belum ada notifikasi.
</div>
)}
</div>
</ComboboxContent>
</Combobox>
<ConfirmDialog
open={clearAllOpen}
onOpenChange={setClearAllOpen}
title="Hapus Semua Notifikasi"
description="Apakah Anda yakin ingin menghapus semua notifikasi? Tindakan ini tidak dapat dibatalkan."
confirmLabel="Hapus"
onConfirm={handleClearAll}
/>
</>
);
}

View File

@ -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) {

View File

@ -13,6 +13,7 @@ declare module '@inertiajs/core' {
name: string;
auth: Auth;
sidebarOpen: boolean;
unreadNotificationsCount: number;
[key: string]: unknown;
};
}

View File

@ -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;
};

View File

@ -1,11 +1,19 @@
<?php
use App\Http\Controllers\NotificationController;
use Illuminate\Support\Facades\Route;
Route::inertia('/', 'welcome')->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';