feat: enhance notification system with creator information and notification sending functionality
This commit is contained in:
parent
5cc33cce2c
commit
8d56ed16ac
@ -23,4 +23,9 @@ public function user(): BelongsTo
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function creator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,16 @@
|
|||||||
namespace App\Services\Admin\Manage;
|
namespace App\Services\Admin\Manage;
|
||||||
|
|
||||||
use App\Models\Announcement;
|
use App\Models\Announcement;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Services\NotificationService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
class AnnouncementService
|
class AnnouncementService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NotificationService $notificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Announcement::query()
|
return Announcement::query()
|
||||||
@ -20,13 +26,27 @@ public function paginated(int $perPage = 25, string $search = '', ?int $departme
|
|||||||
|
|
||||||
public function create(array $data): Announcement
|
public function create(array $data): Announcement
|
||||||
{
|
{
|
||||||
return Announcement::create([
|
$announcement = Announcement::create([
|
||||||
'title' => $data['title'],
|
'title' => $data['title'],
|
||||||
'content' => $data['content'],
|
'content' => $data['content'],
|
||||||
'department_id' => $data['department_id'] ?? null,
|
'department_id' => $data['department_id'] ?? null,
|
||||||
'enrollment_year' => $data['enrollment_year'] ?? null,
|
'enrollment_year' => $data['enrollment_year'] ?? null,
|
||||||
'created_by' => auth()->id(),
|
'created_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$recipientUserIds = Student::query()
|
||||||
|
->when($announcement->department_id, fn ($q, $departmentId) => $q->where('department_id', $departmentId))
|
||||||
|
->when($announcement->enrollment_year, fn ($q, $year) => $q->where('enrollment_year', $year))
|
||||||
|
->pluck('user_id');
|
||||||
|
|
||||||
|
$this->notificationService->sendToUsers(
|
||||||
|
$recipientUserIds,
|
||||||
|
$announcement->title,
|
||||||
|
$announcement->content,
|
||||||
|
$announcement->created_by,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $announcement;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Announcement $announcement, array $data): Announcement
|
public function update(Announcement $announcement, array $data): Announcement
|
||||||
|
|||||||
@ -5,13 +5,40 @@
|
|||||||
use App\Models\Notification;
|
use App\Models\Notification;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class NotificationService
|
class NotificationService
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param Collection<int, int>|array<int, int> $userIds
|
||||||
|
*/
|
||||||
|
public function sendToUsers(Collection|array $userIds, string $title, ?string $content = null, ?int $createdBy = null): void
|
||||||
|
{
|
||||||
|
$userIds = collect($userIds)->filter()->unique()->values();
|
||||||
|
|
||||||
|
if ($userIds->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdBy ??= auth()->id();
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
Notification::insert($userIds->map(fn (int $userId) => [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'created_by' => $createdBy,
|
||||||
|
'title' => $title,
|
||||||
|
'content' => $content,
|
||||||
|
'is_read' => false,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
])->all());
|
||||||
|
}
|
||||||
|
|
||||||
public function paginated(User $user, int $perPage = 15): LengthAwarePaginator
|
public function paginated(User $user, int $perPage = 15): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Notification::query()
|
return Notification::query()
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
|
->with('creator.profile')
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ public function up(): void
|
|||||||
Schema::create('notifications', function (Blueprint $table) {
|
Schema::create('notifications', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
$table->string('title', 150)->nullable();
|
$table->string('title', 150)->nullable();
|
||||||
$table->text('content')->nullable();
|
$table->text('content')->nullable();
|
||||||
$table->boolean('is_read')->nullable()->default(false);
|
$table->boolean('is_read')->nullable()->default(false);
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?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::table('notifications', function (Blueprint $table) {
|
||||||
|
$table->foreignId('created_by')->nullable()->after('user_id')->constrained('users')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('notifications', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('created_by');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -6,6 +6,13 @@ import { useState } from 'react';
|
|||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Combobox, ComboboxContent } from '@/components/ui/combobox';
|
import { Combobox, ComboboxContent } from '@/components/ui/combobox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { destroy, markAllRead, clearAll, read } from '@/routes/notifications';
|
import { destroy, markAllRead, clearAll, read } from '@/routes/notifications';
|
||||||
import type { Notification, NotificationPage } from '@/types/notification';
|
import type { Notification, NotificationPage } from '@/types/notification';
|
||||||
@ -23,6 +30,7 @@ export function NotificationBell() {
|
|||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [clearAllOpen, setClearAllOpen] = useState(false);
|
const [clearAllOpen, setClearAllOpen] = useState(false);
|
||||||
|
const [detail, setDetail] = useState<Notification | null>(null);
|
||||||
|
|
||||||
const hasMore = notifications.current_page < notifications.last_page;
|
const hasMore = notifications.current_page < notifications.last_page;
|
||||||
const hasNotifications = notifications.data.length > 0;
|
const hasNotifications = notifications.data.length > 0;
|
||||||
@ -47,6 +55,14 @@ export function NotificationBell() {
|
|||||||
router.delete(destroy(notification.id).url, { preserveScroll: true });
|
router.delete(destroy(notification.id).url, { preserveScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpenDetail(notification: Notification) {
|
||||||
|
setDetail(notification);
|
||||||
|
|
||||||
|
if (!notification.is_read) {
|
||||||
|
handleMarkRead(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Combobox open={open} onOpenChange={setOpen}>
|
<Combobox open={open} onOpenChange={setOpen}>
|
||||||
@ -70,9 +86,9 @@ export function NotificationBell() {
|
|||||||
|
|
||||||
<ComboboxContent
|
<ComboboxContent
|
||||||
align="end"
|
align="end"
|
||||||
className="w-96 max-w-[90vw] min-w-96 p-0"
|
className="flex w-96 max-w-[90vw] min-w-96 flex-col p-0"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b p-3">
|
<div className="flex shrink-0 items-center justify-between border-b p-3">
|
||||||
<span className="font-medium">Notifikasi</span>
|
<span className="font-medium">Notifikasi</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
@ -96,7 +112,7 @@ export function NotificationBell() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{hasNotifications ? (
|
{hasNotifications ? (
|
||||||
<div className="flex flex-col divide-y">
|
<div className="flex flex-col divide-y">
|
||||||
{notifications.data.map((notification) => (
|
{notifications.data.map((notification) => (
|
||||||
@ -108,7 +124,13 @@ export function NotificationBell() {
|
|||||||
'bg-primary/5',
|
'bg-primary/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 cursor-pointer items-start gap-2 text-left"
|
||||||
|
onClick={() =>
|
||||||
|
handleOpenDetail(notification)
|
||||||
|
}
|
||||||
|
>
|
||||||
{!notification.is_read && (
|
{!notification.is_read && (
|
||||||
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
@ -117,11 +139,14 @@ export function NotificationBell() {
|
|||||||
{notification.title ?? '-'}
|
{notification.title ?? '-'}
|
||||||
</p>
|
</p>
|
||||||
{notification.content && (
|
{notification.content && (
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
||||||
{notification.content}
|
{notification.content}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{notification.creator
|
||||||
|
?.profile?.full_name &&
|
||||||
|
`${notification.creator.profile.full_name} · `}
|
||||||
{format(
|
{format(
|
||||||
new Date(
|
new Date(
|
||||||
notification.created_at,
|
notification.created_at,
|
||||||
@ -130,7 +155,7 @@ export function NotificationBell() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{!notification.is_read && (
|
{!notification.is_read && (
|
||||||
<Button
|
<Button
|
||||||
@ -202,6 +227,35 @@ export function NotificationBell() {
|
|||||||
confirmLabel="Hapus"
|
confirmLabel="Hapus"
|
||||||
onConfirm={handleClearAll}
|
onConfirm={handleClearAll}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={detail !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{detail?.title ?? '-'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{detail?.creator?.profile?.full_name &&
|
||||||
|
`${detail.creator.profile.full_name} · `}
|
||||||
|
{detail &&
|
||||||
|
format(
|
||||||
|
new Date(detail.created_at),
|
||||||
|
'd MMM yyyy, HH:mm',
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{detail?.content && (
|
||||||
|
<p className="overflow-y-auto text-sm whitespace-pre-wrap text-foreground">
|
||||||
|
{detail.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ export type Notification = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
is_read: boolean;
|
is_read: boolean;
|
||||||
|
creator: { profile: { full_name: string } | null } | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user