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;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Notification;
|
||||||
use App\Models\PushSubscription;
|
use App\Models\PushSubscription;
|
||||||
use App\Models\SystemConfiguration;
|
use App\Models\SystemConfiguration;
|
||||||
|
use App\Models\User;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -24,6 +26,26 @@ public function __construct(
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(): void
|
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();
|
$subscriptions = $this->resolveSubscriptions();
|
||||||
|
|
||||||
@ -99,4 +121,23 @@ private function resolveSubscriptions()
|
|||||||
|
|
||||||
return $query->get();
|
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');
|
return $this->hasMany(Purchase::class, 'created_by_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function notifications(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Notification::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function pushSubscriptions(): HasMany
|
public function pushSubscriptions(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(PushSubscription::class);
|
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">
|
<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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -11,9 +15,6 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { logout as logoutRoute } from '@/routes';
|
import { logout as logoutRoute } from '@/routes';
|
||||||
import { profile } from '@/routes/admin/account';
|
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';
|
import Separator from './ui/separator/Separator.vue';
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
@ -33,6 +34,7 @@ function logout() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<NotificationBell />
|
||||||
<DropdownMenu v-if="user">
|
<DropdownMenu v-if="user">
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" class="relative size-8 rounded-full">
|
<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\Master\SupplierController;
|
||||||
use App\Http\Controllers\Admin\System\ActivityLogController;
|
use App\Http\Controllers\Admin\System\ActivityLogController;
|
||||||
use App\Http\Controllers\Admin\System\RoleController;
|
use App\Http\Controllers\Admin\System\RoleController;
|
||||||
|
use App\Http\Controllers\Admin\NotificationController;
|
||||||
use App\Http\Controllers\Admin\System\SettingController;
|
use App\Http\Controllers\Admin\System\SettingController;
|
||||||
use App\Http\Controllers\Auth\LoginController;
|
use App\Http\Controllers\Auth\LoginController;
|
||||||
use App\Http\Controllers\Auth\LogoutController;
|
use App\Http\Controllers\Auth\LogoutController;
|
||||||
@ -50,6 +51,13 @@
|
|||||||
Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])->name('push_subscriptions.store');
|
Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])->name('push_subscriptions.store');
|
||||||
Route::delete('/push-subscriptions', [PushSubscriptionController::class, 'destroy'])->name('push_subscriptions.destroy');
|
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::prefix('admin')->name('admin.')->group(function () {
|
||||||
Route::get('/dashboard', [DashboardController::class, 'index'])
|
Route::get('/dashboard', [DashboardController::class, 'index'])
|
||||||
->name('dashboard');
|
->name('dashboard');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user