From e268153971c409e30944f33bbd03270dfb2ee0da Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Tue, 25 Aug 2026 01:03:20 +0700 Subject: [PATCH] feat: add announcement management functionality with CRUD operations --- .../Admin/Manage/AnnouncementController.php | 54 +++ .../Admin/Manage/AnnouncementRequest.php | 24 ++ app/Models/Announcement.php | 25 ++ .../Admin/Manage/AnnouncementService.php | 46 +++ .../Admin/Master/DepartmentService.php | 6 + ...8_25_000002_create_announcements_table.php | 27 ++ database/seeders/AnnouncementSeeder.php | 33 ++ database/seeders/DatabaseSeeder.php | 1 + resources/js/components/app-sidebar.tsx | 12 + .../admin/manage/announcements/columns.tsx | 99 ++++++ .../admin/manage/announcements/index.tsx | 318 ++++++++++++++++++ resources/js/types/announcement.ts | 23 ++ routes/admin.php | 3 + 13 files changed, 671 insertions(+) create mode 100644 app/Http/Controllers/Admin/Manage/AnnouncementController.php create mode 100644 app/Http/Requests/Admin/Manage/AnnouncementRequest.php create mode 100644 app/Models/Announcement.php create mode 100644 app/Services/Admin/Manage/AnnouncementService.php create mode 100644 database/migrations/2026_08_25_000002_create_announcements_table.php create mode 100644 database/seeders/AnnouncementSeeder.php create mode 100644 resources/js/pages/admin/manage/announcements/columns.tsx create mode 100644 resources/js/pages/admin/manage/announcements/index.tsx create mode 100644 resources/js/types/announcement.ts diff --git a/app/Http/Controllers/Admin/Manage/AnnouncementController.php b/app/Http/Controllers/Admin/Manage/AnnouncementController.php new file mode 100644 index 0000000..46373ce --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/AnnouncementController.php @@ -0,0 +1,54 @@ + $this->service->paginated(...$request->validatedWithDefaults()), + 'departments' => $this->departmentService->getAll(), + ]); + } + + public function store(AnnouncementRequest $request): RedirectResponse + { + $this->service->create($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengumuman berhasil ditambahkan.']); + + return to_route('admin.manage.announcements.index'); + } + + public function update(AnnouncementRequest $request, Announcement $announcement): RedirectResponse + { + $this->service->update($announcement, $request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengumuman berhasil diperbarui.']); + + return to_route('admin.manage.announcements.index'); + } + + public function destroy(Announcement $announcement): RedirectResponse + { + $this->service->delete($announcement); + + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengumuman berhasil dihapus.'])->back(); + } +} diff --git a/app/Http/Requests/Admin/Manage/AnnouncementRequest.php b/app/Http/Requests/Admin/Manage/AnnouncementRequest.php new file mode 100644 index 0000000..cc12a46 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/AnnouncementRequest.php @@ -0,0 +1,24 @@ + ['required', 'string', 'max:200'], + 'content' => ['required', 'string'], + 'department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')], + 'enrollment_year' => ['nullable', 'integer', 'digits:4'], + ]; + } +} diff --git a/app/Models/Announcement.php b/app/Models/Announcement.php new file mode 100644 index 0000000..9b63673 --- /dev/null +++ b/app/Models/Announcement.php @@ -0,0 +1,25 @@ +belongsTo(Department::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/app/Services/Admin/Manage/AnnouncementService.php b/app/Services/Admin/Manage/AnnouncementService.php new file mode 100644 index 0000000..617f491 --- /dev/null +++ b/app/Services/Admin/Manage/AnnouncementService.php @@ -0,0 +1,46 @@ +select(['id', 'title', 'content', 'department_id', 'enrollment_year', 'created_by', 'created_at']) + ->with(['department:id,name', 'creator.profile']) + ->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%")) + ->orderBy($sort, $direction) + ->paginate($perPage); + } + + public function create(array $data): Announcement + { + return Announcement::create([ + 'title' => $data['title'], + 'content' => $data['content'], + 'department_id' => $data['department_id'] ?? null, + 'enrollment_year' => $data['enrollment_year'] ?? null, + 'created_by' => auth()->id(), + ]); + } + + public function update(Announcement $announcement, array $data): Announcement + { + $announcement->title = $data['title']; + $announcement->content = $data['content']; + $announcement->department_id = $data['department_id'] ?? null; + $announcement->enrollment_year = $data['enrollment_year'] ?? null; + $announcement->update(); + + return $announcement; + } + + public function delete(Announcement $announcement): bool + { + return $announcement->delete(); + } +} diff --git a/app/Services/Admin/Master/DepartmentService.php b/app/Services/Admin/Master/DepartmentService.php index 7814ef4..e33a389 100644 --- a/app/Services/Admin/Master/DepartmentService.php +++ b/app/Services/Admin/Master/DepartmentService.php @@ -4,9 +4,15 @@ use App\Models\Department; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Database\Eloquent\Collection; class DepartmentService { + public function getAll(): Collection + { + return Department::select(['id', 'code', 'name'])->get(); + } + public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator { return Department::query() diff --git a/database/migrations/2026_08_25_000002_create_announcements_table.php b/database/migrations/2026_08_25_000002_create_announcements_table.php new file mode 100644 index 0000000..8964695 --- /dev/null +++ b/database/migrations/2026_08_25_000002_create_announcements_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('title', 200); + $table->text('content'); + $table->foreignId('department_id')->nullable()->constrained()->nullOnDelete(); + $table->integer('enrollment_year')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('announcements'); + } +}; diff --git a/database/seeders/AnnouncementSeeder.php b/database/seeders/AnnouncementSeeder.php new file mode 100644 index 0000000..1a3b6c7 --- /dev/null +++ b/database/seeders/AnnouncementSeeder.php @@ -0,0 +1,33 @@ + 'Libur Idul Fitri', + 'content' => 'Perkuliahan diliburkan sesuai kalender akademik', + 'department_id' => null, + 'enrollment_year' => null, + 'created_by' => 5, + 'created_at' => '2026-03-01 08:00:00', + 'updated_at' => '2026-03-01 08:00:00', + ], + [ + 'title' => 'Jadwal UTS Prodi SI', + 'content' => 'UTS Sistem Informasi dilaksanakan tanggal 20-25 Oktober', + 'department_id' => 1, + 'enrollment_year' => null, + 'created_by' => 5, + 'created_at' => '2026-10-01 08:00:00', + 'updated_at' => '2026-10-01 08:00:00', + ], + ]); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 1caaf11..c1836d7 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -28,6 +28,7 @@ public function run(): void AttendanceSeeder::class, TuitionInvoiceSeeder::class, TuitionPaymentSeeder::class, + AnnouncementSeeder::class, ]); } } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index b427dd2..eb7236f 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -16,6 +16,7 @@ import { FileCheck2, FileText, GraduationCap, + Megaphone, Receipt, School, User, @@ -35,6 +36,7 @@ import { SidebarMenuButton, SidebarMenuItem, } from '@/components/ui/sidebar'; +import { index as announcementsRoute } from '@/routes/admin/manage/announcements'; import { index as assignmentsRoute } from '@/routes/admin/manage/assignments'; import { index as attendancesRoute } from '@/routes/admin/manage/attendances'; import { index as courseClassesRoute } from '@/routes/admin/manage/course-classes'; @@ -129,6 +131,16 @@ const data: { }, ], }, + { + label: 'Pengumuman', + items: [ + { + name: 'Pengumuman', + url: announcementsRoute.url(), + icon: Megaphone, + }, + ], + }, { label: 'Pengguna', items: [ diff --git a/resources/js/pages/admin/manage/announcements/columns.tsx b/resources/js/pages/admin/manage/announcements/columns.tsx new file mode 100644 index 0000000..ed2e9ac --- /dev/null +++ b/resources/js/pages/admin/manage/announcements/columns.tsx @@ -0,0 +1,99 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { format } from 'date-fns'; +import { Pencil, Trash2 } from 'lucide-react'; +import { RowActions } from '@/components/row-actions'; +import { Badge } from '@/components/ui/badge'; +import type { Announcement } from '@/types/announcement'; + +export type { Announcement } from '@/types/announcement'; + +type CreateColumnsParams = { + handleEdit: (announcement: Announcement) => void; + handleDeleteClick: (announcement: Announcement) => void; +}; + +export function createAnnouncementColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleEdit, handleDeleteClick } = params; + + return [ + { + accessorKey: 'title', + header: () => Judul, + cell: ({ row }) => { + const announcement = row.original; + + return ( +
+

{announcement.title}

+

+ {announcement.content} +

+
+ ); + }, + }, + { + accessorKey: 'department.name', + header: () => Target, + cell: ({ row }) => { + const announcement = row.original; + + if (!announcement.department && !announcement.enrollment_year) { + return Semua Mahasiswa; + } + + return ( +
+ + {announcement.department?.name ?? 'Semua Jurusan'} + + {announcement.enrollment_year && ( + + Angkatan {announcement.enrollment_year} + + )} +
+ ); + }, + }, + { + accessorKey: 'creator.profile.full_name', + header: () => Dibuat Oleh, + cell: ({ row }) => row.original.creator?.profile?.full_name ?? '-', + }, + { + accessorKey: 'created_at', + header: () => Tanggal, + cell: ({ row }) => + format(new Date(row.original.created_at), 'd MMM yyyy, HH:mm'), + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => ( + , + onClick: () => handleEdit(row.original), + }, + { + label: 'Hapus', + icon: ( + + ), + onClick: () => handleDeleteClick(row.original), + }, + ]} + /> + ), + }, + ]; +} diff --git a/resources/js/pages/admin/manage/announcements/index.tsx b/resources/js/pages/admin/manage/announcements/index.tsx new file mode 100644 index 0000000..2b19684 --- /dev/null +++ b/resources/js/pages/admin/manage/announcements/index.tsx @@ -0,0 +1,318 @@ +import { Head, router } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useRef, useState } from 'react'; +import type { PaginationState } from '@/components/data-table'; +import { DataTable } from '@/components/data-table'; +import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; +import { FormDialog } from '@/components/form-dialog'; +import InputError from '@/components/input-error'; +import { PageHeader } from '@/components/page-header'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { useServerTable } from '@/hooks/use-server-table'; +import { + index as announcementIndex, + destroy, + store, + update, +} from '@/routes/admin/manage/announcements'; +import type { + Announcement, + AnnouncementDepartment, +} from '@/types/announcement'; +import { createAnnouncementColumns } from './columns'; + +type Props = { + announcements: { + data: Announcement[]; + current_page: number; + last_page: number; + per_page: number; + total: number; + }; + departments: AnnouncementDepartment[]; + highlight?: number; +}; + +export default function AnnouncementIndex({ + announcements, + departments, + highlight, +}: Props) { + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + const pagination: PaginationState = { + current_page: announcements.current_page, + last_page: announcements.last_page, + per_page: announcements.per_page, + total: announcements.total, + }; + + const { + search, + handlePageChange, + handlePerPageChange, + handleSearchChange, + } = useServerTable({ + route: () => announcementIndex.url(), + pagination, + }); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(destroy(deleting.id), { + onSuccess: () => setDeleting(null), + }); + } + + const columns = createAnnouncementColumns({ + handleEdit: (announcement) => setEditing(announcement), + handleDeleteClick: (announcement) => setDeleting(announcement), + }); + + return ( + <> + + +
+ + Menampilkan pengumuman dari notifikasi. +

+ ) + } + actions={ + + + } + /> + + + + { + if (!open) { + setEditing(null); + } + }} + editing={editing} + departments={departments} + /> + + + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Pengumuman" + description={(announcement) => + `Apakah Anda yakin ingin menghapus pengumuman "${announcement.title}"? Tindakan ini tidak dapat dibatalkan.` + } + onConfirm={handleDelete} + /> +
+ + ); +} + +function AnnouncementFields({ + errors, + editing, + departments, +}: { + errors: Record; + editing?: Announcement; + departments: AnnouncementDepartment[]; +}) { + const hiddenDeptRef = useRef(null); + const initialDeptValue = editing?.department_id + ? String(editing.department_id) + : ''; + + return ( + <> +
+ + + +
+
+ +