siakad-itm/resources/js/pages/admin/academic-classes/attendances/index.tsx
Yoga Pangestu 5994f38f01 feat: implement permission checks for academic terms, courses, departments, and user management
- Added permission checks for creating, updating, and deleting academic terms, courses, and departments.
- Updated the routes to enforce permissions for various actions in the admin panel.
- Enhanced user management by adding permissions for administrators, lecturers, and students.
- Refactored components to conditionally render actions based on user permissions.
- Updated the auth type to include optional permissions array for user roles.
2026-08-30 23:27:31 +07:00

283 lines
12 KiB
TypeScript

import { Head, Link, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { ClipboardCheck, Pencil, Plus, Trash2, X } from 'lucide-react';
import { useState } from 'react';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { PageHeader } from '@/components/page-header';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
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 { usePermissions } from '@/hooks/use-permissions';
import { destroy, session } from '@/routes/admin/academic-classes/attendances';
import type {
AttendanceCourseClass,
AttendanceSession,
} from '@/types/attendance';
type Props = {
sessions: AttendanceSession[];
courseClasses: AttendanceCourseClass[];
};
function courseClassLabel(courseClass: AttendanceCourseClass): string {
return `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`;
}
export default function AttendanceIndex({ sessions, courseClasses }: Props) {
const [newSessionOpen, setNewSessionOpen] = useState(false);
const [deleting, setDeleting] = useState<AttendanceSession | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-attendances');
const canDelete = hasPermission('delete-attendances');
function handleDelete() {
if (!deleting || deleting.meeting_number === null) {
return;
}
router.delete(
destroy([deleting.course_class_id, deleting.meeting_number]).url,
{ onSuccess: () => setDeleting(null) },
);
}
return (
<>
<Head title="Kehadiran" />
<div className="flex h-full flex-1 flex-col gap-6 p-4 md:p-6">
<PageHeader
title="Kehadiran"
actions={
canCreate && (
<Button onClick={() => setNewSessionOpen(true)}>
<Plus className="h-4 w-4" />
Ambil Kehadiran
</Button>
)
}
/>
{sessions.length === 0 ? (
<p className="text-sm text-muted-foreground">
Belum ada data kehadiran.
</p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sessions.map((item) => {
const key = `${item.course_class_id}-${item.meeting_number}-${item.date}`;
const canOpen = item.meeting_number !== null;
return (
<Card key={key}>
<CardHeader className="flex flex-row items-start justify-between gap-2">
<CardTitle className="text-sm leading-tight">
{item.course_class
? courseClassLabel(
item.course_class,
)
: '-'}
</CardTitle>
<Badge variant="secondary">
{item.present_count}/
{item.total_count} Hadir
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className="text-xs text-muted-foreground">
Pertemuan ke-
{item.meeting_number ?? '-'}{' '}
&middot;{' '}
{format(
new Date(item.date),
'd MMM yyyy',
)}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
asChild
disabled={!canOpen}
>
{canOpen ? (
<Link
href={
session([
item.course_class_id,
item.meeting_number as number,
]).url
}
>
<Pencil className="h-4 w-4" />
Lihat / Edit
</Link>
) : (
<span>
<Pencil className="h-4 w-4" />
Lihat / Edit
</span>
)}
</Button>
{canDelete && (
<Button
variant="ghost"
size="sm"
disabled={!canOpen}
onClick={() =>
setDeleting(item)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<NewSessionDialog
open={newSessionOpen}
onOpenChange={setNewSessionOpen}
courseClasses={courseClasses}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Sesi Kehadiran"
description={(item) =>
`Apakah Anda yakin ingin menghapus seluruh data kehadiran pertemuan ke-${item.meeting_number} untuk kelas ini? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>
</>
);
}
function NewSessionDialog({
open,
onOpenChange,
courseClasses,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
courseClasses: AttendanceCourseClass[];
}) {
const [courseClassId, setCourseClassId] = useState('');
const [meetingNumber, setMeetingNumber] = useState('');
function reset() {
setCourseClassId('');
setMeetingNumber('');
}
function handleOpenSession() {
if (!courseClassId || !meetingNumber) {
return;
}
router.get(session([Number(courseClassId), Number(meetingNumber)]).url);
}
return (
<Dialog
open={open}
onOpenChange={(value) => {
onOpenChange(value);
if (!value) {
reset();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Ambil Kehadiran</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>
Kelas <span className="text-destructive">*</span>
</Label>
<Select
value={courseClassId}
onValueChange={setCourseClassId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih kelas" />
</SelectTrigger>
<SelectContent>
{courseClasses.map((courseClass) => (
<SelectItem
key={courseClass.id}
value={String(courseClass.id)}
>
{courseClassLabel(courseClass)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="meeting_number">
Pertemuan Ke-{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="meeting_number"
type="number"
min={1}
placeholder="Contoh: 1"
value={meetingNumber}
onChange={(e) => setMeetingNumber(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
Batal
</Button>
<Button
type="button"
disabled={!courseClassId || !meetingNumber}
onClick={handleOpenSession}
>
<ClipboardCheck className="h-4 w-4" />
Buka Kehadiran
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}