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:
parent
37ce3c15ea
commit
900c27041a
@ -7,7 +7,6 @@
|
|||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Announcement;
|
use App\Models\Announcement;
|
||||||
use App\Services\Admin\Manage\AnnouncementService;
|
use App\Services\Admin\Manage\AnnouncementService;
|
||||||
use App\Services\Admin\Master\DepartmentService;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@ -16,18 +15,15 @@ class AnnouncementController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AnnouncementService $service,
|
private readonly AnnouncementService $service,
|
||||||
private readonly DepartmentService $departmentService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/manage/announcements/index', [
|
return Inertia::render('admin/manage/announcements/index', [
|
||||||
'announcements' => $this->service->paginated(
|
'announcements' => $this->service->paginated(
|
||||||
|
$request->user(),
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
departmentId: $request->validated('department_id'),
|
|
||||||
),
|
),
|
||||||
'departments' => $this->departmentService->getAllForSelect(),
|
|
||||||
'filters' => $request->only(['department_id']),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\Admin\Manage;
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@ -17,8 +18,10 @@ public function rules(): array
|
|||||||
return [
|
return [
|
||||||
'title' => ['required', 'string', 'max:200'],
|
'title' => ['required', 'string', 'max:200'],
|
||||||
'content' => ['required', 'string'],
|
'content' => ['required', 'string'],
|
||||||
'department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')],
|
'target_roles' => ['required', 'array', 'min:1'],
|
||||||
'enrollment_year' => ['nullable', 'integer', 'digits:4'],
|
'target_roles.*' => [
|
||||||
|
Rule::enum(UserRole::class)->only([UserRole::Mahasiswa, UserRole::Dosen]),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,13 +13,15 @@ class Announcement extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'target_roles' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function creator(): BelongsTo
|
public function creator(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'created_by');
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function department(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Department::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,11 +28,6 @@ public function currentLeader(): HasOne
|
|||||||
return $this->hasOne(DepartmentLeadership::class)->whereNull('ended_at');
|
return $this->hasOne(DepartmentLeadership::class)->whereNull('ended_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function announcements(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Announcement::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function courses(): HasMany
|
public function courses(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Course::class);
|
return $this->hasMany(Course::class);
|
||||||
|
|||||||
@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin\Manage;
|
namespace App\Services\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Announcement;
|
use App\Models\Announcement;
|
||||||
use App\Models\Student;
|
use App\Models\User;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
@ -13,15 +14,15 @@ public function __construct(
|
|||||||
private readonly NotificationService $notificationService,
|
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()
|
return Announcement::query()
|
||||||
->select(['id', 'title', 'content', 'department_id', 'enrollment_year', 'created_by', 'created_at'])
|
->with(['creator.profile'])
|
||||||
->with(['department:id,name', 'creator.profile'])
|
|
||||||
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
|
->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()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['id', 'title', 'content', 'target_roles', 'created_by', 'created_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): Announcement
|
public function create(array $data): Announcement
|
||||||
@ -29,15 +30,13 @@ public function create(array $data): Announcement
|
|||||||
$announcement = Announcement::create([
|
$announcement = Announcement::create([
|
||||||
'title' => $data['title'],
|
'title' => $data['title'],
|
||||||
'content' => $data['content'],
|
'content' => $data['content'],
|
||||||
'department_id' => $data['department_id'] ?? null,
|
'target_roles' => $data['target_roles'],
|
||||||
'enrollment_year' => $data['enrollment_year'] ?? null,
|
|
||||||
'created_by' => auth()->id(),
|
'created_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$recipientUserIds = Student::query()
|
$recipientUserIds = User::query()
|
||||||
->when($announcement->department_id, fn ($q, $departmentId) => $q->where('department_id', $departmentId))
|
->whereHas('roles', fn ($q) => $q->whereIn('name', $announcement->target_roles))
|
||||||
->when($announcement->enrollment_year, fn ($q, $year) => $q->where('enrollment_year', $year))
|
->pluck('id');
|
||||||
->pluck('user_id');
|
|
||||||
|
|
||||||
$this->notificationService->sendToUsers(
|
$this->notificationService->sendToUsers(
|
||||||
$recipientUserIds,
|
$recipientUserIds,
|
||||||
@ -51,11 +50,11 @@ public function create(array $data): Announcement
|
|||||||
|
|
||||||
public function update(Announcement $announcement, array $data): Announcement
|
public function update(Announcement $announcement, array $data): Announcement
|
||||||
{
|
{
|
||||||
$announcement->title = $data['title'];
|
$announcement->update([
|
||||||
$announcement->content = $data['content'];
|
'title' => $data['title'],
|
||||||
$announcement->department_id = $data['department_id'] ?? null;
|
'content' => $data['content'],
|
||||||
$announcement->enrollment_year = $data['enrollment_year'] ?? null;
|
'target_roles' => $data['target_roles'],
|
||||||
$announcement->update();
|
]);
|
||||||
|
|
||||||
return $announcement;
|
return $announcement;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,8 +12,7 @@ public function up(): void
|
|||||||
$table->id();
|
$table->id();
|
||||||
$table->string('title', 200);
|
$table->string('title', 200);
|
||||||
$table->text('content');
|
$table->text('content');
|
||||||
$table->foreignId('department_id')->nullable()->constrained()->nullOnDelete();
|
$table->json('target_roles');
|
||||||
$table->integer('enrollment_year')->nullable();
|
|
||||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->softDeletes();
|
$table->softDeletes();
|
||||||
|
|||||||
@ -13,8 +13,7 @@ public function run(): void
|
|||||||
[
|
[
|
||||||
'title' => 'Libur Idul Fitri',
|
'title' => 'Libur Idul Fitri',
|
||||||
'content' => 'Perkuliahan diliburkan sesuai kalender akademik',
|
'content' => 'Perkuliahan diliburkan sesuai kalender akademik',
|
||||||
'department_id' => null,
|
'target_roles' => json_encode(['mahasiswa', 'dosen']),
|
||||||
'enrollment_year' => null,
|
|
||||||
'created_by' => 5,
|
'created_by' => 5,
|
||||||
'created_at' => '2026-03-01 08:00:00',
|
'created_at' => '2026-03-01 08:00:00',
|
||||||
'updated_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',
|
'title' => 'Jadwal UTS Prodi SI',
|
||||||
'content' => 'UTS Sistem Informasi dilaksanakan tanggal 20-25 Oktober',
|
'content' => 'UTS Sistem Informasi dilaksanakan tanggal 20-25 Oktober',
|
||||||
'department_id' => 1,
|
'target_roles' => json_encode(['mahasiswa']),
|
||||||
'enrollment_year' => null,
|
|
||||||
'created_by' => 5,
|
'created_by' => 5,
|
||||||
'created_at' => '2026-10-01 08:00:00',
|
'created_at' => '2026-10-01 08:00:00',
|
||||||
'updated_at' => '2026-10-01 08:00:00',
|
'updated_at' => '2026-10-01 08:00:00',
|
||||||
|
|||||||
@ -52,6 +52,7 @@ public function run(): void
|
|||||||
'view-assignments',
|
'view-assignments',
|
||||||
'submit-assignments',
|
'submit-assignments',
|
||||||
'view-own-attendances',
|
'view-own-attendances',
|
||||||
|
'view-announcements',
|
||||||
...$feedbackSelfService,
|
...$feedbackSelfService,
|
||||||
],
|
],
|
||||||
'dosen' => [
|
'dosen' => [
|
||||||
@ -80,6 +81,7 @@ public function run(): void
|
|||||||
'create-letter-requests',
|
'create-letter-requests',
|
||||||
'update-letter-requests',
|
'update-letter-requests',
|
||||||
'delete-letter-requests',
|
'delete-letter-requests',
|
||||||
|
'view-announcements',
|
||||||
...$feedbackSelfService,
|
...$feedbackSelfService,
|
||||||
],
|
],
|
||||||
'staff-admin' => [
|
'staff-admin' => [
|
||||||
|
|||||||
@ -1,65 +1,61 @@
|
|||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { format } from 'date-fns';
|
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 { RowActions } from '@/components/row-actions';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import type { Announcement } from '@/types/announcement';
|
import type { Announcement } from '@/types/announcement';
|
||||||
|
import { AnnouncementTargetRoleLabels } from '@/types/announcement';
|
||||||
|
|
||||||
export type { Announcement } from '@/types/announcement';
|
export type { Announcement } from '@/types/announcement';
|
||||||
|
|
||||||
type CreateColumnsParams = {
|
type CreateColumnsParams = {
|
||||||
|
handleView: (announcement: Announcement) => void;
|
||||||
handleEdit: (announcement: Announcement) => void;
|
handleEdit: (announcement: Announcement) => void;
|
||||||
handleDeleteClick: (announcement: Announcement) => void;
|
handleDeleteClick: (announcement: Announcement) => void;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
showTargetRoles: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createAnnouncementColumns(
|
export function createAnnouncementColumns(
|
||||||
params: CreateColumnsParams,
|
params: CreateColumnsParams,
|
||||||
): ColumnDef<Announcement>[] {
|
): ColumnDef<Announcement>[] {
|
||||||
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
|
const {
|
||||||
|
handleView,
|
||||||
|
handleEdit,
|
||||||
|
handleDeleteClick,
|
||||||
|
canUpdate,
|
||||||
|
canDelete,
|
||||||
|
showTargetRoles,
|
||||||
|
} = params;
|
||||||
|
|
||||||
const columns: ColumnDef<Announcement>[] = [
|
const columns: ColumnDef<Announcement>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
header: () => <span>Judul</span>,
|
header: () => <span>Judul</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (
|
||||||
const announcement = row.original;
|
<span className="font-medium">{row.original.title}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
if (showTargetRoles) {
|
||||||
<div>
|
columns.push({
|
||||||
<p className="font-medium">{announcement.title}</p>
|
accessorKey: 'target_roles',
|
||||||
<p className="line-clamp-1 max-w-md text-xs text-muted-foreground">
|
header: () => <span>Ditujukan Untuk</span>,
|
||||||
{announcement.content}
|
cell: ({ row }) => (
|
||||||
</p>
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{row.original.target_roles.map((role) => (
|
||||||
|
<Badge key={role} variant="secondary">
|
||||||
|
{AnnouncementTargetRoleLabels[role]}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
</div>
|
</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 (
|
columns.push(
|
||||||
<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',
|
accessorKey: 'creator.profile.full_name',
|
||||||
header: () => <span>Dibuat Oleh</span>,
|
header: () => <span>Dibuat Oleh</span>,
|
||||||
@ -71,9 +67,8 @@ export function createAnnouncementColumns(
|
|||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
format(new Date(row.original.created_at), 'd MMM yyyy, HH:mm'),
|
format(new Date(row.original.created_at), 'd MMM yyyy, HH:mm'),
|
||||||
},
|
},
|
||||||
];
|
);
|
||||||
|
|
||||||
if (canUpdate || canDelete) {
|
|
||||||
columns.push({
|
columns.push({
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: () => <span className="block text-center">Aksi</span>,
|
header: () => <span className="block text-center">Aksi</span>,
|
||||||
@ -84,6 +79,11 @@ export function createAnnouncementColumns(
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<RowActions
|
<RowActions
|
||||||
actions={[
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Lihat Detail',
|
||||||
|
icon: <Eye className="h-4 w-4" />,
|
||||||
|
onClick: () => handleView(row.original),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
icon: <Pencil className="h-4 w-4" />,
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
@ -102,7 +102,6 @@ export function createAnnouncementColumns(
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,24 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
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 { FormDialog } from '@/components/form-dialog';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { useServerTable } from '@/hooks/use-server-table';
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
@ -28,11 +28,11 @@ import {
|
|||||||
store,
|
store,
|
||||||
update,
|
update,
|
||||||
} from '@/routes/admin/manage/announcements';
|
} from '@/routes/admin/manage/announcements';
|
||||||
import type {
|
import type { Announcement } from '@/types/announcement';
|
||||||
Announcement,
|
import {
|
||||||
AnnouncementDepartment,
|
AnnouncementTargetRoleLabels,
|
||||||
|
AnnouncementTargetRoles,
|
||||||
} from '@/types/announcement';
|
} from '@/types/announcement';
|
||||||
import { formatDepartmentLabel } from '@/types/department';
|
|
||||||
import { createAnnouncementColumns } from './columns';
|
import { createAnnouncementColumns } from './columns';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -43,38 +43,22 @@ type Props = {
|
|||||||
per_page: number;
|
per_page: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
departments: AnnouncementDepartment[];
|
|
||||||
highlight?: number;
|
highlight?: number;
|
||||||
filters: {
|
|
||||||
department_id?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AnnouncementIndex({
|
export default function AnnouncementIndex({
|
||||||
announcements,
|
announcements,
|
||||||
departments,
|
|
||||||
highlight,
|
highlight,
|
||||||
filters,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Announcement | null>(null);
|
const [editing, setEditing] = useState<Announcement | null>(null);
|
||||||
const [deleting, setDeleting] = useState<Announcement | null>(null);
|
const [deleting, setDeleting] = useState<Announcement | null>(null);
|
||||||
|
const [viewing, setViewing] = useState<Announcement | null>(null);
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission } = usePermissions();
|
||||||
const canCreate = hasPermission('create-announcements');
|
const canCreate = hasPermission('create-announcements');
|
||||||
const canUpdate = hasPermission('update-announcements');
|
const canUpdate = hasPermission('update-announcements');
|
||||||
const canDelete = hasPermission('delete-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 = {
|
const pagination: PaginationState = {
|
||||||
current_page: announcements.current_page,
|
current_page: announcements.current_page,
|
||||||
last_page: announcements.last_page,
|
last_page: announcements.last_page,
|
||||||
@ -82,16 +66,10 @@ export default function AnnouncementIndex({
|
|||||||
total: announcements.total,
|
total: announcements.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const { search, handlePageChange, handlePerPageChange, handleSearchChange } =
|
||||||
search,
|
useServerTable({
|
||||||
handlePageChange,
|
|
||||||
handlePerPageChange,
|
|
||||||
handleSearchChange,
|
|
||||||
applyFilters,
|
|
||||||
} = useServerTable({
|
|
||||||
route: () => announcementIndex.url(),
|
route: () => announcementIndex.url(),
|
||||||
pagination,
|
pagination,
|
||||||
filters,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
@ -105,10 +83,12 @@ export default function AnnouncementIndex({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns = createAnnouncementColumns({
|
const columns = createAnnouncementColumns({
|
||||||
|
handleView: (announcement) => setViewing(announcement),
|
||||||
handleEdit: (announcement) => setEditing(announcement),
|
handleEdit: (announcement) => setEditing(announcement),
|
||||||
handleDeleteClick: (announcement) => setDeleting(announcement),
|
handleDeleteClick: (announcement) => setDeleting(announcement),
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
showTargetRoles: canCreate || canUpdate,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -140,11 +120,7 @@ export default function AnnouncementIndex({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateForm
|
<CreateForm open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={setCreateOpen}
|
|
||||||
departments={departments}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditForm
|
<EditForm
|
||||||
key={editing?.id}
|
key={editing?.id}
|
||||||
@ -155,7 +131,16 @@ export default function AnnouncementIndex({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
departments={departments}
|
/>
|
||||||
|
|
||||||
|
<ViewDetailDialog
|
||||||
|
open={viewing !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setViewing(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
announcement={viewing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
@ -167,13 +152,6 @@ export default function AnnouncementIndex({
|
|||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
searchValue={search}
|
searchValue={search}
|
||||||
searchKey="title"
|
searchKey="title"
|
||||||
toolbar={
|
|
||||||
<FilterDialog
|
|
||||||
fields={filterFields}
|
|
||||||
activeFilters={filters}
|
|
||||||
onApply={applyFilters}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<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 ?? '-'}{' '}
|
||||||
|
·{' '}
|
||||||
|
{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({
|
function AnnouncementFields({
|
||||||
errors,
|
errors,
|
||||||
editing,
|
editing,
|
||||||
departments,
|
|
||||||
}: {
|
}: {
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
editing?: Announcement;
|
editing?: Announcement;
|
||||||
departments: AnnouncementDepartment[];
|
|
||||||
}) {
|
}) {
|
||||||
const hiddenDeptRef = useRef<HTMLInputElement>(null);
|
|
||||||
const initialDeptValue = editing?.department_id
|
|
||||||
? String(editing.department_id)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@ -237,50 +273,31 @@ function AnnouncementFields({
|
|||||||
<InputError message={errors.content} />
|
<InputError message={errors.content} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Jurusan</Label>
|
<Label>
|
||||||
<input
|
Ditujukan Untuk{' '}
|
||||||
ref={hiddenDeptRef}
|
<span className="text-destructive">*</span>
|
||||||
type="hidden"
|
</Label>
|
||||||
name="department_id"
|
<div className="flex flex-wrap gap-4">
|
||||||
defaultValue={initialDeptValue}
|
{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,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Label
|
||||||
defaultValue={initialDeptValue || 'none'}
|
htmlFor={`target-role-${role}`}
|
||||||
onValueChange={(value) => {
|
className="font-normal"
|
||||||
if (hiddenDeptRef.current) {
|
|
||||||
hiddenDeptRef.current.value =
|
|
||||||
value === 'none' ? '' : value;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
{AnnouncementTargetRoleLabels[role]}
|
||||||
<SelectValue placeholder="Semua Jurusan" />
|
</Label>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">Semua Jurusan</SelectItem>
|
|
||||||
{departments.map((department) => (
|
|
||||||
<SelectItem
|
|
||||||
key={department.id}
|
|
||||||
value={String(department.id)}
|
|
||||||
>
|
|
||||||
{formatDepartmentLabel(department)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<InputError message={errors.department_id} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
))}
|
||||||
<Label htmlFor="enrollment_year">Angkatan</Label>
|
</div>
|
||||||
<Input
|
<InputError message={errors.target_roles} />
|
||||||
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -289,11 +306,9 @@ function AnnouncementFields({
|
|||||||
function CreateForm({
|
function CreateForm({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
departments,
|
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
departments: AnnouncementDepartment[];
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
@ -306,10 +321,7 @@ function CreateForm({
|
|||||||
>
|
>
|
||||||
{({ errors }) => (
|
{({ errors }) => (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<AnnouncementFields
|
<AnnouncementFields errors={errors} />
|
||||||
errors={errors}
|
|
||||||
departments={departments}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
@ -320,12 +332,10 @@ function EditForm({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
editing,
|
editing,
|
||||||
departments,
|
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
editing: Announcement | null;
|
editing: Announcement | null;
|
||||||
departments: AnnouncementDepartment[];
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
@ -342,7 +352,6 @@ function EditForm({
|
|||||||
<AnnouncementFields
|
<AnnouncementFields
|
||||||
errors={errors}
|
errors={errors}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
departments={departments}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
export type AnnouncementDepartment = {
|
export const AnnouncementTargetRoles = ['mahasiswa', 'dosen'] as const;
|
||||||
id: number;
|
|
||||||
code: string;
|
export type AnnouncementTargetRole = (typeof AnnouncementTargetRoles)[number];
|
||||||
name: string;
|
|
||||||
degree_level: string | null;
|
export const AnnouncementTargetRoleLabels: Record<
|
||||||
|
AnnouncementTargetRole,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
mahasiswa: 'Mahasiswa',
|
||||||
|
dosen: 'Dosen',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AnnouncementCreator = {
|
export type AnnouncementCreator = {
|
||||||
@ -14,9 +19,7 @@ export type Announcement = {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
department_id: number | null;
|
target_roles: AnnouncementTargetRole[];
|
||||||
department: AnnouncementDepartment | null;
|
|
||||||
enrollment_year: number | null;
|
|
||||||
created_by: number | null;
|
created_by: number | null;
|
||||||
creator: AnnouncementCreator | null;
|
creator: AnnouncementCreator | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user