feat: implement notification system with controller, model, and frontend integration

This commit is contained in:
Yoga Pangestu 2026-06-27 20:45:01 +07:00
parent 7c0b59b998
commit 4fb137d57d
8 changed files with 501 additions and 3 deletions

View File

@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Notification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request): JsonResponse
{
$user = $request->user();
$notifications = Notification::query()
->where('user_id', $user->id)
->latest()
->limit(20)
->get()
->map(fn (Notification $n) => [
'id' => $n->id,
'title' => $n->title,
'body' => $n->body,
'url' => $n->url,
'is_read' => $n->is_read,
'created_at' => $n->created_at->toIso8601String(),
]);
$unreadCount = Notification::query()
->where('user_id', $user->id)
->unread()
->count();
return response()->json([
'notifications' => $notifications,
'unread_count' => $unreadCount,
]);
}
public function markAsRead(Request $request, Notification $notification): JsonResponse
{
if ($notification->user_id !== $request->user()->id) {
abort(403);
}
$notification->markAsRead();
return response()->json(['success' => true]);
}
public function markAllAsRead(Request $request): JsonResponse
{
Notification::query()
->where('user_id', $request->user()->id)
->unread()
->update(['is_read' => true, 'read_at' => now()]);
return response()->json(['success' => true]);
}
public function destroy(Request $request, Notification $notification): JsonResponse
{
if ($notification->user_id !== $request->user()->id) {
abort(403);
}
$notification->delete();
return response()->json(['success' => true]);
}
public function destroyAll(Request $request): JsonResponse
{
Notification::query()
->where('user_id', $request->user()->id)
->delete();
return response()->json(['success' => true]);
}
}

View File

@ -2,8 +2,10 @@
namespace App\Jobs;
use App\Models\Notification;
use App\Models\PushSubscription;
use App\Models\SystemConfiguration;
use App\Models\User;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
@ -24,6 +26,26 @@ public function __construct(
) {}
public function handle(): void
{
$this->persistNotifications();
$this->sendWebPush();
}
private function persistNotifications(): void
{
$userIds = $this->resolveUserIds();
foreach ($userIds as $uid) {
Notification::create([
'user_id' => $uid,
'title' => $this->title,
'body' => $this->body,
'url' => $this->url,
]);
}
}
private function sendWebPush(): void
{
$subscriptions = $this->resolveSubscriptions();
@ -99,4 +121,23 @@ private function resolveSubscriptions()
return $query->get();
}
/**
* @return list<int>
*/
private function resolveUserIds(): array
{
if ($this->userId !== null) {
return [$this->userId];
}
if (! empty($this->roles)) {
return User::query()
->whereHas('roles', fn (Builder $q) => $q->whereIn('name', $this->roles))
->pluck('id')
->all();
}
return User::query()->pluck('id')->all();
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
class Notification extends Model
{
protected function casts(): array
{
return [
'is_read' => 'boolean',
'read_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function markAsRead(): void
{
if (! $this->is_read) {
$this->update(['is_read' => true, 'read_at' => now()]);
}
}
#[Scope]
public function unread(Builder $query): void
{
$query->where('is_read', false);
}
}

View File

@ -105,6 +105,11 @@ public function purchases(): HasMany
return $this->hasMany(Purchase::class, 'created_by_id');
}
public function notifications(): HasMany
{
return $this->hasMany(Notification::class);
}
public function pushSubscriptions(): HasMany
{
return $this->hasMany(PushSubscription::class);

View File

@ -0,0 +1,31 @@
<?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): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('body')->nullable();
$table->string('url')->nullable();
$table->boolean('is_read')->default(false);
$table->timestamp('read_at')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
});
}
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@ -0,0 +1,291 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { Bell, CheckCheck, Eye, Trash2, X } from '@lucide/vue';
import { onMounted, onUnmounted, ref } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface NotificationItem {
id: number;
title: string;
body: string | null;
url: string | null;
is_read: boolean;
created_at: string;
}
const notifications = ref<NotificationItem[]>([]);
const unreadCount = ref(0);
const isOpen = ref(false);
let interval: ReturnType<typeof setInterval> | null = null;
function csrfToken(): string {
return (
document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement
)?.content ?? '';
}
async function fetchNotifications(): Promise<void> {
try {
const response = await fetch('/notifications', {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (response.ok) {
const data = (await response.json()) as {
notifications: NotificationItem[];
unread_count: number;
};
notifications.value = data.notifications;
unreadCount.value = data.unread_count;
}
} catch {
// silent
}
}
async function markAsRead(notification: NotificationItem): Promise<void> {
if (notification.is_read) {
return;
}
try {
await fetch(`/notifications/${notification.id}/read`, {
method: 'PATCH',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
});
notification.is_read = true;
unreadCount.value = Math.max(0, unreadCount.value - 1);
} catch {
// silent
}
}
function openNotification(notification: NotificationItem): void {
if (notification.url) {
isOpen.value = false;
router.visit(notification.url);
}
}
async function deleteNotification(id: number): Promise<void> {
try {
await fetch(`/notifications/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
});
const idx = notifications.value.findIndex((n) => n.id === id);
if (idx !== -1) {
if (!notifications.value[idx].is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
notifications.value.splice(idx, 1);
}
} catch {
// silent
}
}
async function markAllAsRead(): Promise<void> {
try {
await fetch('/notifications/read-all', {
method: 'PATCH',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
});
notifications.value.forEach((n) => {
n.is_read = true;
});
unreadCount.value = 0;
} catch {
// silent
}
}
async function deleteAll(): Promise<void> {
try {
await fetch('/notifications', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
});
notifications.value = [];
unreadCount.value = 0;
} catch {
// silent
}
}
function timeAgo(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const seconds = Math.floor((now - then) / 1000);
if (seconds < 60) {
return 'Baru saja';
}
if (seconds < 3600) {
return `${Math.floor(seconds / 60)}m lalu`;
}
if (seconds < 86400) {
return `${Math.floor(seconds / 3600)}j lalu`;
}
return `${Math.floor(seconds / 86400)}h lalu`;
}
onMounted(() => {
void fetchNotifications();
interval = setInterval(() => void fetchNotifications(), 30000);
});
onUnmounted(() => {
if (interval) {
clearInterval(interval);
}
});
</script>
<template>
<TooltipProvider>
<Popover v-model:open="isOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="relative size-8">
<Bell class="size-4" />
<Badge
v-if="unreadCount > 0"
variant="destructive"
class="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full p-0 text-[10px]"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent align="end" :side-offset="8" class="w-80 p-0">
<!-- Header -->
<div class="flex items-center justify-between border-b px-4 py-3">
<h4 class="text-sm font-semibold">Notifikasi</h4>
<div class="flex items-center gap-1">
<Tooltip v-if="unreadCount > 0">
<TooltipTrigger as-child>
<button
class="flex items-center rounded p-1 text-muted-foreground hover:text-foreground"
@click="markAllAsRead"
>
<CheckCheck class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Tandai semua dibaca</TooltipContent>
</Tooltip>
<Tooltip v-if="notifications.length > 0">
<TooltipTrigger as-child>
<button
class="flex items-center rounded p-1 text-muted-foreground hover:text-destructive"
@click="deleteAll"
>
<Trash2 class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Hapus semua</TooltipContent>
</Tooltip>
</div>
</div>
<!-- List -->
<div class="max-h-80 overflow-y-auto">
<div
v-if="notifications.length === 0"
class="px-4 py-8 text-center text-sm text-muted-foreground"
>
Tidak ada notifikasi
</div>
<div
v-for="notification in notifications"
:key="notification.id"
class="group flex items-start gap-3 border-b px-4 py-3 transition-colors hover:bg-accent"
:class="{ 'bg-primary/5': !notification.is_read }"
>
<!-- Unread dot -->
<div
class="mt-1.5 size-2 shrink-0 rounded-full"
:class="notification.is_read ? 'bg-transparent' : 'bg-primary'"
/>
<!-- Content -->
<div
class="min-w-0 flex-1 cursor-pointer"
@click="openNotification(notification)"
>
<p class="text-sm font-medium leading-tight">
{{ notification.title }}
</p>
<p
v-if="notification.body"
class="mt-0.5 text-xs leading-snug text-muted-foreground line-clamp-2"
>
{{ notification.body }}
</p>
<p class="mt-1 text-[10px] text-muted-foreground">
{{ timeAgo(notification.created_at) }}
</p>
</div>
<!-- Actions -->
<div
class="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
>
<Tooltip v-if="!notification.is_read">
<TooltipTrigger as-child>
<button
class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
@click.stop="markAsRead(notification)"
>
<Eye class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Tandai dibaca</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive"
@click.stop="deleteNotification(notification.id)"
>
<X class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Hapus</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</TooltipProvider>
</template>

View File

@ -1,4 +1,8 @@
<script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3';
import { LogOut, User } from '@lucide/vue';
import { computed } from 'vue';
import NotificationBell from '@/components/NotificationBell.vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@ -11,9 +15,6 @@ import {
} from '@/components/ui/dropdown-menu';
import { logout as logoutRoute } from '@/routes';
import { profile } from '@/routes/admin/account';
import { router, usePage } from '@inertiajs/vue3';
import { LogOut, User } from '@lucide/vue';
import { computed } from 'vue';
import Separator from './ui/separator/Separator.vue';
const page = usePage();
@ -33,6 +34,7 @@ function logout() {
<template>
<div class="flex items-center gap-2">
<NotificationBell />
<DropdownMenu v-if="user">
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="relative size-8 rounded-full">

View File

@ -29,6 +29,7 @@
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Admin\System\ActivityLogController;
use App\Http\Controllers\Admin\System\RoleController;
use App\Http\Controllers\Admin\NotificationController;
use App\Http\Controllers\Admin\System\SettingController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;
@ -50,6 +51,13 @@
Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])->name('push_subscriptions.store');
Route::delete('/push-subscriptions', [PushSubscriptionController::class, 'destroy'])->name('push_subscriptions.destroy');
// Notifications
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
Route::patch('/notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->name('notifications.read');
Route::patch('/notifications/read-all', [NotificationController::class, 'markAllAsRead'])->name('notifications.read_all');
Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy'])->name('notifications.destroy');
Route::delete('/notifications', [NotificationController::class, 'destroyAll'])->name('notifications.destroy_all');
Route::prefix('admin')->name('admin.')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');