256 lines
9.8 KiB
TypeScript
256 lines
9.8 KiB
TypeScript
import { Head, Link, router } from '@inertiajs/react';
|
|
import { format } from 'date-fns';
|
|
import { ArrowLeft, ClipboardCheck, Pencil, Plus, Trash2 } 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 { usePermissions } from '@/hooks/use-permissions';
|
|
import {
|
|
destroy,
|
|
index as attendanceIndex,
|
|
session,
|
|
} from '@/routes/admin/academic-classes/attendances';
|
|
import { formatAcademicTermLabel } from '@/types/academic-term';
|
|
import type {
|
|
AttendanceCourseClass,
|
|
AttendanceSession,
|
|
} from '@/types/attendance';
|
|
|
|
type Props = {
|
|
courseClass: AttendanceCourseClass;
|
|
sessions: AttendanceSession[];
|
|
nextMeetingNumber: number;
|
|
};
|
|
|
|
function courseClassLabel(courseClass: AttendanceCourseClass): string {
|
|
return `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`;
|
|
}
|
|
|
|
export default function AttendanceCourseClass({
|
|
courseClass,
|
|
sessions,
|
|
nextMeetingNumber,
|
|
}: 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) {
|
|
return;
|
|
}
|
|
|
|
router.delete(destroy([courseClass.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={courseClassLabel(courseClass)}
|
|
description={
|
|
courseClass.academic_term && (
|
|
<Badge variant="outline" className="mt-1 w-fit">
|
|
{formatAcademicTermLabel(
|
|
courseClass.academic_term,
|
|
)}
|
|
</Badge>
|
|
)
|
|
}
|
|
actions={
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" asChild>
|
|
<Link href={attendanceIndex.url()}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Kembali
|
|
</Link>
|
|
</Button>
|
|
{canCreate && (
|
|
<Button onClick={() => setNewSessionOpen(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
Tambah Pertemuan
|
|
</Button>
|
|
)}
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{sessions.length === 0 ? (
|
|
<p className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
|
Belum ada pertemuan yang tercatat.
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{sessions.map((item) => (
|
|
<Card key={item.meeting_number}>
|
|
<CardHeader className="flex flex-row items-start justify-between gap-2">
|
|
<CardTitle className="text-sm leading-tight">
|
|
Pertemuan ke-{item.meeting_number}
|
|
</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">
|
|
{format(
|
|
new Date(item.date),
|
|
'd MMM yyyy',
|
|
)}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
asChild
|
|
>
|
|
<Link
|
|
href={
|
|
session([
|
|
courseClass.id,
|
|
item.meeting_number,
|
|
]).url
|
|
}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
Lihat / Edit
|
|
</Link>
|
|
</Button>
|
|
{canDelete && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
setDeleting(item)
|
|
}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<NewSessionDialog
|
|
open={newSessionOpen}
|
|
onOpenChange={setNewSessionOpen}
|
|
courseClassId={courseClass.id}
|
|
defaultMeetingNumber={nextMeetingNumber}
|
|
/>
|
|
|
|
<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}? Tindakan ini tidak dapat dibatalkan.`
|
|
}
|
|
onConfirm={handleDelete}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function NewSessionDialog({
|
|
open,
|
|
onOpenChange,
|
|
courseClassId,
|
|
defaultMeetingNumber,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
courseClassId: number;
|
|
defaultMeetingNumber: number;
|
|
}) {
|
|
const [meetingNumber, setMeetingNumber] = useState(
|
|
String(defaultMeetingNumber),
|
|
);
|
|
|
|
function handleOpenSession() {
|
|
if (!meetingNumber) {
|
|
return;
|
|
}
|
|
|
|
router.get(session([courseClassId, Number(meetingNumber)]).url);
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(value) => {
|
|
onOpenChange(value);
|
|
|
|
if (!value) {
|
|
setMeetingNumber(String(defaultMeetingNumber));
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Tambah Pertemuan</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<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}
|
|
value={meetingNumber}
|
|
onChange={(e) => setMeetingNumber(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
>
|
|
Batal
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={!meetingNumber}
|
|
onClick={handleOpenSession}
|
|
>
|
|
<ClipboardCheck className="h-4 w-4" />
|
|
Buka Kehadiran
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|