feat: update Announcement management to support target roles; refactor Announcement model, service, and controller; enhance UI for role-based visibility

This commit is contained in:
Yoga Pangestu 2026-09-03 15:45:38 +07:00
parent 37ce3c15ea
commit 900c27041a
11 changed files with 234 additions and 229 deletions

View File

@ -7,7 +7,6 @@
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;
@ -16,18 +15,15 @@ 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->user(),
...$request->validatedWithDefaults(),
departmentId: $request->validated('department_id'),
),
'departments' => $this->departmentService->getAllForSelect(),
'filters' => $request->only(['department_id']),
]);
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Admin\Manage;
use App\Enums\UserRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -17,8 +18,10 @@ 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'],
'target_roles' => ['required', 'array', 'min:1'],
'target_roles.*' => [
Rule::enum(UserRole::class)->only([UserRole::Mahasiswa, UserRole::Dosen]),
],
];
}
}

View File

@ -13,13 +13,15 @@ class Announcement extends Model
{
use HasFactory, SoftDeletes;
protected function casts(): array
{
return [
'target_roles' => 'array',
];
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
}

View File

@ -28,11 +28,6 @@ public function currentLeader(): HasOne
return $this->hasOne(DepartmentLeadership::class)->whereNull('ended_at');
}
public function announcements(): HasMany
{
return $this->hasMany(Announcement::class);
}
public function courses(): HasMany
{
return $this->hasMany(Course::class);

View File

@ -2,8 +2,9 @@
namespace App\Services\Admin\Manage;
use App\Enums\UserRole;
use App\Models\Announcement;
use App\Models\Student;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -13,15 +14,15 @@ public function __construct(
private readonly NotificationService $notificationService,
) {}
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
public function paginated(User $user, int $perPage = 25, string $search = ''): LengthAwarePaginator
{
return Announcement::query()
->select(['id', 'title', 'content', 'department_id', 'enrollment_year', 'created_by', 'created_at'])
->with(['department:id,name', 'creator.profile'])
->with(['creator.profile'])
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
->when($departmentId, fn ($q) => $q->where('department_id', $departmentId))
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereJsonContains('target_roles', UserRole::Mahasiswa->value))
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereJsonContains('target_roles', UserRole::Dosen->value))
->latest()
->paginate($perPage);
->paginate($perPage, ['id', 'title', 'content', 'target_roles', 'created_by', 'created_at']);
}
public function create(array $data): Announcement
@ -29,15 +30,13 @@ public function create(array $data): Announcement
$announcement = Announcement::create([
'title' => $data['title'],
'content' => $data['content'],
'department_id' => $data['department_id'] ?? null,
'enrollment_year' => $data['enrollment_year'] ?? null,
'target_roles' => $data['target_roles'],
'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');
$recipientUserIds = User::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', $announcement->target_roles))
->pluck('id');
$this->notificationService->sendToUsers(
$recipientUserIds,
@ -51,11 +50,11 @@ public function create(array $data): Announcement
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();
$announcement->update([
'title' => $data['title'],
'content' => $data['content'],
'target_roles' => $data['target_roles'],
]);
return $announcement;
}

View File

@ -12,8 +12,7 @@ public function up(): void
$table->id();
$table->string('title', 200);
$table->text('content');
$table->foreignId('department_id')->nullable()->constrained()->nullOnDelete();
$table->integer('enrollment_year')->nullable();
$table->json('target_roles');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->softDeletes();

View File

@ -13,8 +13,7 @@ public function run(): void
[
'title' => 'Libur Idul Fitri',
'content' => 'Perkuliahan diliburkan sesuai kalender akademik',
'department_id' => null,
'enrollment_year' => null,
'target_roles' => json_encode(['mahasiswa', 'dosen']),
'created_by' => 5,
'created_at' => '2026-03-01 08:00:00',
'updated_at' => '2026-03-01 08:00:00',
@ -22,8 +21,7 @@ public function run(): void
[
'title' => 'Jadwal UTS Prodi SI',
'content' => 'UTS Sistem Informasi dilaksanakan tanggal 20-25 Oktober',
'department_id' => 1,
'enrollment_year' => null,
'target_roles' => json_encode(['mahasiswa']),
'created_by' => 5,
'created_at' => '2026-10-01 08:00:00',
'updated_at' => '2026-10-01 08:00:00',

View File

@ -52,6 +52,7 @@ public function run(): void
'view-assignments',
'submit-assignments',
'view-own-attendances',
'view-announcements',
...$feedbackSelfService,
],
'dosen' => [
@ -80,6 +81,7 @@ public function run(): void
'create-letter-requests',
'update-letter-requests',
'delete-letter-requests',
'view-announcements',
...$feedbackSelfService,
],
'staff-admin' => [

View File

@ -1,65 +1,61 @@
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { Pencil, Trash2 } from 'lucide-react';
import { Eye, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import type { Announcement } from '@/types/announcement';
import { AnnouncementTargetRoleLabels } from '@/types/announcement';
export type { Announcement } from '@/types/announcement';
type CreateColumnsParams = {
handleView: (announcement: Announcement) => void;
handleEdit: (announcement: Announcement) => void;
handleDeleteClick: (announcement: Announcement) => void;
canUpdate: boolean;
canDelete: boolean;
showTargetRoles: boolean;
};
export function createAnnouncementColumns(
params: CreateColumnsParams,
): ColumnDef<Announcement>[] {
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
const {
handleView,
handleEdit,
handleDeleteClick,
canUpdate,
canDelete,
showTargetRoles,
} = params;
const columns: ColumnDef<Announcement>[] = [
{
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>
);
},
cell: ({ row }) => (
<span className="font-medium">{row.original.title}</span>
),
},
{
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'}
if (showTargetRoles) {
columns.push({
accessorKey: 'target_roles',
header: () => <span>Ditujukan Untuk</span>,
cell: ({ row }) => (
<div className="flex flex-wrap gap-1">
{row.original.target_roles.map((role) => (
<Badge key={role} variant="secondary">
{AnnouncementTargetRoleLabels[role]}
</Badge>
{announcement.enrollment_year && (
<Badge variant="secondary">
Angkatan {announcement.enrollment_year}
</Badge>
)}
</div>
);
},
},
))}
</div>
),
});
}
columns.push(
{
accessorKey: 'creator.profile.full_name',
header: () => <span>Dibuat Oleh</span>,
@ -71,38 +67,41 @@ export function createAnnouncementColumns(
cell: ({ row }) =>
format(new Date(row.original.created_at), 'd MMM yyyy, HH:mm'),
},
];
);
if (canUpdate || canDelete) {
columns.push({
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" />,
show: canUpdate,
onClick: () => handleEdit(row.original),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(row.original),
},
]}
/>
),
});
}
columns.push({
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: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
onClick: () => handleView(row.original),
},
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(row.original),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(row.original),
},
]}
/>
),
});
return columns;
}

View File

@ -1,24 +1,24 @@
import { Head, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { Plus } from 'lucide-react';
import { useRef, useState } from 'react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
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 { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
@ -28,11 +28,11 @@ import {
store,
update,
} from '@/routes/admin/manage/announcements';
import type {
Announcement,
AnnouncementDepartment,
import type { Announcement } from '@/types/announcement';
import {
AnnouncementTargetRoleLabels,
AnnouncementTargetRoles,
} from '@/types/announcement';
import { formatDepartmentLabel } from '@/types/department';
import { createAnnouncementColumns } from './columns';
type Props = {
@ -43,38 +43,22 @@ type Props = {
per_page: number;
total: number;
};
departments: AnnouncementDepartment[];
highlight?: number;
filters: {
department_id?: string;
};
};
export default function AnnouncementIndex({
announcements,
departments,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Announcement | null>(null);
const [deleting, setDeleting] = useState<Announcement | null>(null);
const [viewing, setViewing] = useState<Announcement | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-announcements');
const canUpdate = hasPermission('update-announcements');
const canDelete = hasPermission('delete-announcements');
const filterFields: FilterField[] = [
{
key: 'department_id',
label: 'Jurusan',
options: departments.map((department) => ({
value: String(department.id),
label: formatDepartmentLabel(department),
})),
},
];
const pagination: PaginationState = {
current_page: announcements.current_page,
last_page: announcements.last_page,
@ -82,17 +66,11 @@ export default function AnnouncementIndex({
total: announcements.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => announcementIndex.url(),
pagination,
filters,
});
const { search, handlePageChange, handlePerPageChange, handleSearchChange } =
useServerTable({
route: () => announcementIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
@ -105,10 +83,12 @@ export default function AnnouncementIndex({
}
const columns = createAnnouncementColumns({
handleView: (announcement) => setViewing(announcement),
handleEdit: (announcement) => setEditing(announcement),
handleDeleteClick: (announcement) => setDeleting(announcement),
canUpdate,
canDelete,
showTargetRoles: canCreate || canUpdate,
});
return (
@ -140,11 +120,7 @@ export default function AnnouncementIndex({
}
/>
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
departments={departments}
/>
<CreateForm open={createOpen} onOpenChange={setCreateOpen} />
<EditForm
key={editing?.id}
@ -155,7 +131,16 @@ export default function AnnouncementIndex({
}
}}
editing={editing}
departments={departments}
/>
<ViewDetailDialog
open={viewing !== null}
onOpenChange={(open) => {
if (!open) {
setViewing(null);
}
}}
announcement={viewing}
/>
<DataTable
@ -167,13 +152,6 @@ export default function AnnouncementIndex({
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="title"
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
<DeleteConfirmDialog
@ -194,20 +172,78 @@ export default function AnnouncementIndex({
);
}
function ViewDetailDialog({
open,
onOpenChange,
announcement,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
announcement: Announcement | null;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-lg">
<DialogHeader className="shrink-0">
<DialogTitle>Detail Pengumuman</DialogTitle>
</DialogHeader>
{announcement && (
<div className="grid min-h-0 flex-1 gap-4 overflow-x-hidden overflow-y-auto">
<span className="text-xs text-muted-foreground">
Dibuat oleh{' '}
{announcement.creator?.profile?.full_name ?? '-'}{' '}
&middot;{' '}
{format(
new Date(announcement.created_at),
'd MMM yyyy, HH:mm',
)}
</span>
<div className="grid gap-1">
<Label className="text-muted-foreground">
Ditujukan Untuk
</Label>
<div className="flex flex-wrap gap-1">
{announcement.target_roles.map((role) => (
<Badge key={role} variant="secondary">
{AnnouncementTargetRoleLabels[role]}
</Badge>
))}
</div>
</div>
<div className="grid gap-1">
<Label className="text-muted-foreground">
Judul
</Label>
<p className="font-medium">
{announcement.title}
</p>
</div>
<div className="grid gap-1">
<Label className="text-muted-foreground">
Isi
</Label>
<p className="whitespace-pre-line">
{announcement.content}
</p>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
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">
@ -237,50 +273,31 @@ function AnnouncementFields({
<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)}
<Label>
Ditujukan Untuk{' '}
<span className="text-destructive">*</span>
</Label>
<div className="flex flex-wrap gap-4">
{AnnouncementTargetRoles.map((role) => (
<div key={role} className="flex items-center gap-2">
<Checkbox
id={`target-role-${role}`}
name="target_roles[]"
value={role}
defaultChecked={editing?.target_roles.includes(
role,
)}
/>
<Label
htmlFor={`target-role-${role}`}
className="font-normal"
>
{formatDepartmentLabel(department)}
</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} />
{AnnouncementTargetRoleLabels[role]}
</Label>
</div>
))}
</div>
<InputError message={errors.target_roles} />
</div>
</>
);
@ -289,11 +306,9 @@ function AnnouncementFields({
function CreateForm({
open,
onOpenChange,
departments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
departments: AnnouncementDepartment[];
}) {
return (
<FormDialog
@ -306,10 +321,7 @@ function CreateForm({
>
{({ errors }) => (
<div className="grid gap-4">
<AnnouncementFields
errors={errors}
departments={departments}
/>
<AnnouncementFields errors={errors} />
</div>
)}
</FormDialog>
@ -320,12 +332,10 @@ function EditForm({
open,
onOpenChange,
editing,
departments,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Announcement | null;
departments: AnnouncementDepartment[];
}) {
return (
<FormDialog
@ -342,7 +352,6 @@ function EditForm({
<AnnouncementFields
errors={errors}
editing={editing}
departments={departments}
/>
</div>
)

View File

@ -1,8 +1,13 @@
export type AnnouncementDepartment = {
id: number;
code: string;
name: string;
degree_level: string | null;
export const AnnouncementTargetRoles = ['mahasiswa', 'dosen'] as const;
export type AnnouncementTargetRole = (typeof AnnouncementTargetRoles)[number];
export const AnnouncementTargetRoleLabels: Record<
AnnouncementTargetRole,
string
> = {
mahasiswa: 'Mahasiswa',
dosen: 'Dosen',
};
export type AnnouncementCreator = {
@ -14,9 +19,7 @@ export type Announcement = {
id: number;
title: string;
content: string;
department_id: number | null;
department: AnnouncementDepartment | null;
enrollment_year: number | null;
target_roles: AnnouncementTargetRole[];
created_by: number | null;
creator: AnnouncementCreator | null;
created_at: string;