feat: implement notification system with controller, model, and frontend integration
This commit is contained in:
parent
7c0b59b998
commit
4fb137d57d
81
app/Http/Controllers/Admin/NotificationController.php
Normal file
81
app/Http/Controllers/Admin/NotificationController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
39
app/Models/Notification.php
Normal file
39
app/Models/Notification.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
291
resources/js/components/NotificationBell.vue
Normal file
291
resources/js/components/NotificationBell.vue
Normal 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>
|
||||
@ -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">
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user