feat: add announcement management functionality with CRUD operations
Some checks failed
tests / ci (pull_request) Has been cancelled

This commit is contained in:
Yoga Pangestu 2026-08-25 01:03:20 +07:00
parent 24cda20051
commit e268153971
13 changed files with 671 additions and 0 deletions

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\AnnouncementRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Announcement;
use App\Services\Admin\Manage\AnnouncementService;
use App\Services\Admin\Master\DepartmentService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class AnnouncementController extends Controller
{
public function __construct(
private readonly AnnouncementService $service,
private readonly DepartmentService $departmentService,
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/manage/announcements/index', [
'announcements' => $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();
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AnnouncementRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:200'],
'content' => ['required', 'string'],
'department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')],
'enrollment_year' => ['nullable', 'integer', 'digits:4'],
];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class Announcement extends Model
{
use HasFactory, SoftDeletes;
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Services\Admin\Manage;
use App\Models\Announcement;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class AnnouncementService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Announcement::query()
->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();
}
}

View File

@ -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()

View File

@ -0,0 +1,27 @@
<?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('announcements', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,33 @@
<?php
namespace Database\Seeders;
use App\Models\Announcement;
use Illuminate\Database\Seeder;
class AnnouncementSeeder extends Seeder
{
public function run(): void
{
Announcement::insert([
[
'title' => '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',
],
]);
}
}

View File

@ -28,6 +28,7 @@ public function run(): void
AttendanceSeeder::class,
TuitionInvoiceSeeder::class,
TuitionPaymentSeeder::class,
AnnouncementSeeder::class,
]);
}
}

View File

@ -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: [

View File

@ -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<Announcement>[] {
const { handleEdit, handleDeleteClick } = params;
return [
{
accessorKey: 'title',
header: () => <span>Judul</span>,
cell: ({ row }) => {
const announcement = row.original;
return (
<div>
<p className="font-medium">{announcement.title}</p>
<p className="line-clamp-1 max-w-md text-xs text-muted-foreground">
{announcement.content}
</p>
</div>
);
},
},
{
accessorKey: 'department.name',
header: () => <span>Target</span>,
cell: ({ row }) => {
const announcement = row.original;
if (!announcement.department && !announcement.enrollment_year) {
return <Badge variant="outline">Semua Mahasiswa</Badge>;
}
return (
<div className="flex flex-wrap gap-1">
<Badge variant="secondary">
{announcement.department?.name ?? 'Semua Jurusan'}
</Badge>
{announcement.enrollment_year && (
<Badge variant="secondary">
Angkatan {announcement.enrollment_year}
</Badge>
)}
</div>
);
},
},
{
accessorKey: 'creator.profile.full_name',
header: () => <span>Dibuat Oleh</span>,
cell: ({ row }) => row.original.creator?.profile?.full_name ?? '-',
},
{
accessorKey: 'created_at',
header: () => <span>Tanggal</span>,
cell: ({ row }) =>
format(new Date(row.original.created_at), 'd MMM yyyy, HH:mm'),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(row.original),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(row.original),
},
]}
/>
),
},
];
}

View File

@ -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<Announcement | null>(null);
const [deleting, setDeleting] = useState<Announcement | null>(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 (
<>
<Head title="Pengumuman" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Pengumuman"
description={
highlight && (
<p className="mt-1 text-sm text-muted-foreground">
Menampilkan pengumuman dari notifikasi.
</p>
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
}
/>
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
departments={departments}
/>
<EditForm
key={editing?.id}
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
editing={editing}
departments={departments}
/>
<DataTable
columns={columns}
data={announcements.data}
emptyText="Belum ada pengumuman."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="title"
searchPlaceholder="Cari judul pengumuman..."
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Pengumuman"
description={(announcement) =>
`Apakah Anda yakin ingin menghapus pengumuman "${announcement.title}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>
</>
);
}
function AnnouncementFields({
errors,
editing,
departments,
}: {
errors: Record<string, string>;
editing?: Announcement;
departments: AnnouncementDepartment[];
}) {
const hiddenDeptRef = useRef<HTMLInputElement>(null);
const initialDeptValue = editing?.department_id
? String(editing.department_id)
: '';
return (
<>
<div className="grid gap-2">
<Label htmlFor="title">
Judul <span className="text-destructive">*</span>
</Label>
<Input
id="title"
name="title"
placeholder="Judul pengumuman"
defaultValue={editing?.title ?? ''}
aria-invalid={!!errors.title}
/>
<InputError message={errors.title} />
</div>
<div className="grid gap-2">
<Label htmlFor="content">
Isi <span className="text-destructive">*</span>
</Label>
<Textarea
id="content"
name="content"
placeholder="Isi pengumuman"
defaultValue={editing?.content ?? ''}
aria-invalid={!!errors.content}
/>
<InputError message={errors.content} />
</div>
<div className="grid gap-2">
<Label>Jurusan</Label>
<input
ref={hiddenDeptRef}
type="hidden"
name="department_id"
defaultValue={initialDeptValue}
/>
<Select
defaultValue={initialDeptValue || 'none'}
onValueChange={(value) => {
if (hiddenDeptRef.current) {
hiddenDeptRef.current.value =
value === 'none' ? '' : value;
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Jurusan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Semua Jurusan</SelectItem>
{departments.map((department) => (
<SelectItem
key={department.id}
value={String(department.id)}
>
{department.name}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.department_id} />
</div>
<div className="grid gap-2">
<Label htmlFor="enrollment_year">Angkatan</Label>
<Input
id="enrollment_year"
name="enrollment_year"
type="number"
placeholder="Kosongkan untuk semua angkatan"
defaultValue={editing?.enrollment_year ?? ''}
aria-invalid={!!errors.enrollment_year}
/>
<InputError message={errors.enrollment_year} />
</div>
</>
);
}
function CreateForm({
open,
onOpenChange,
departments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
departments: AnnouncementDepartment[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tambah Pengumuman"
action={store()}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-4">
<AnnouncementFields
errors={errors}
departments={departments}
/>
</div>
)}
</FormDialog>
);
}
function EditForm({
open,
onOpenChange,
editing,
departments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Announcement | null;
departments: AnnouncementDepartment[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Edit Pengumuman"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) =>
editing && (
<div className="grid gap-4">
<AnnouncementFields
errors={errors}
editing={editing}
departments={departments}
/>
</div>
)
}
</FormDialog>
);
}

View File

@ -0,0 +1,23 @@
export type AnnouncementDepartment = {
id: number;
code: string;
name: string;
};
export type AnnouncementCreator = {
id: number;
profile: { full_name: string } | null;
};
export type Announcement = {
id: number;
title: string;
content: string;
department_id: number | null;
department: AnnouncementDepartment | null;
enrollment_year: number | null;
created_by: number | null;
creator: AnnouncementCreator | null;
created_at: string;
updated_at: string;
};

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Admin\Manage\AnnouncementController;
use App\Http\Controllers\Admin\Manage\AssignmentController;
use App\Http\Controllers\Admin\Manage\AttendanceController;
use App\Http\Controllers\Admin\Manage\ClassEnrollmentController;
@ -70,6 +71,8 @@
Route::put('{payment}', [TuitionPaymentController::class, 'update'])->name('update');
Route::delete('{payment}', [TuitionPaymentController::class, 'destroy'])->name('destroy');
});
Route::resource('announcements', AnnouncementController::class)->except(['create', 'edit', 'show']);
});
Route::prefix('admin/users')->name('admin.users.')->group(function () {