itmpwk.ac.id/resources/js/pages/admin/manage/course-registrations/show.tsx

762 lines
32 KiB
TypeScript

import { Form, Head, Link, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { ArrowLeft, Check, Inbox, Send, X } from 'lucide-react';
import { useState } from 'react';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { SignaturePad } from '@/components/signature-pad';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import {
advisor_signature,
approve,
department_leader_signature,
reject,
update,
} from '@/routes/admin/manage/course-registrations';
import type {
CourseRegistrationSubmission,
CourseRegistrationSubmissionEntry,
CourseRegistrationSubmissionLog,
} from '@/types/course-registration';
import {
RegistrationStatusLabels,
semesterKey,
} from '@/types/course-registration';
const RegistrationStatusVariants: Record<
CourseRegistrationSubmission['status'],
'default' | 'secondary' | 'destructive' | 'outline'
> = {
submitted: 'outline',
approved: 'default',
rejected: 'destructive',
};
function isSemesterOpenForStudent(
semester: number | null,
openSemesters: number[],
studentCurrentSemester: number,
): boolean {
return (
semester !== null &&
semester === studentCurrentSemester &&
openSemesters.includes(semester)
);
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[auto_1fr] gap-x-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">: {value}</span>
</div>
);
}
function SignatureBox({
signatureUrl,
alt,
error,
editable,
}: {
signatureUrl: string | null;
alt: string;
error?: string;
editable: boolean;
}) {
if (!editable) {
if (!signatureUrl) {
return <div className="h-28 w-48" />;
}
return (
<div className="h-28 w-48 overflow-hidden rounded border bg-white">
<img
src={signatureUrl}
alt={alt}
className="h-full w-full object-contain"
/>
</div>
);
}
return <SignaturePad name="signature" error={error} compact />;
}
function ReviewerSignatureField({
signatureUrl,
alt,
canSign,
submitUrl,
}: {
signatureUrl: string | null;
alt: string;
canSign: boolean;
submitUrl: string;
}) {
const [file, setFile] = useState<File | null>(null);
const [processing, setProcessing] = useState(false);
if (signatureUrl) {
return (
<div className="h-28 w-48 overflow-hidden rounded border bg-white">
<img
src={signatureUrl}
alt={alt}
className="h-full w-full object-contain"
/>
</div>
);
}
if (!canSign) {
return <div className="h-28 w-48" />;
}
function handleSave() {
if (!file) {
return;
}
setProcessing(true);
router.patch(
submitUrl,
{ signature: file },
{
preserveScroll: true,
onFinish: () => setProcessing(false),
},
);
}
return (
<div className="grid gap-1">
<SignaturePad name="_signature" compact onCapture={setFile} />
<Button
type="button"
size="sm"
disabled={!file || processing}
onClick={handleSave}
>
{processing ? 'Menyimpan...' : 'Simpan TTD'}
</Button>
</div>
);
}
function LogBubble({
log,
isResubmission,
}: {
log: CourseRegistrationSubmissionLog;
isResubmission: boolean;
}) {
const actorName = log.actor?.profile?.full_name ?? 'Sistem';
const message =
log.status === 'approved'
? `${actorName} menyetujui KRS ini.`
: log.status === 'rejected'
? `${actorName} menolak KRS ini dengan alasan: "${log.reason}"`
: isResubmission
? `${actorName} mengajukan ulang KRS.`
: `${actorName} mengajukan KRS.`;
const colorClasses =
log.status === 'approved'
? 'border-primary/30 bg-primary/10'
: log.status === 'rejected'
? 'border-destructive/30 bg-destructive/10'
: 'border-border bg-muted';
return (
<div className={`rounded-lg border px-3 py-2 ${colorClasses}`}>
<p className="text-sm">{message}</p>
<p className="mt-1 text-xs text-muted-foreground">
{format(new Date(log.created_at), 'd MMM yyyy, HH:mm')}
</p>
</div>
);
}
function RejectDialog({
open,
onOpenChange,
submissionId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
submissionId: number;
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tolak Registrasi KRS"
action={reject(submissionId)}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-2">
<Label htmlFor="reason">
Alasan Penolakan{' '}
<span className="text-destructive">*</span>
</Label>
<Textarea
id="reason"
name="reason"
placeholder="Jelaskan alasan penolakan"
/>
<InputError message={errors.reason} />
</div>
)}
</FormDialog>
);
}
function SemesterCard({
semester,
submission,
studentId,
isSemesterOpen,
backHref,
canReview,
canSignAsDepartmentLeader,
canSignAsAdvisor,
}: {
semester: number | null;
submission: CourseRegistrationSubmission;
studentId: number;
isSemesterOpen: boolean;
backHref: string | null;
canReview: boolean;
canSignAsDepartmentLeader: boolean;
canSignAsAdvisor: boolean;
}) {
const [rejecting, setRejecting] = useState(false);
const student = submission.student;
const department = student?.department ?? null;
const departmentLeader = department?.current_leader?.lecturer ?? null;
const advisor = student?.academic_advisor ?? null;
const isViewOnly = backHref !== null;
const notYetSubmitted =
submission.status === 'submitted' && !submission.has_registrations;
const isLocked =
submission.status === 'approved' ||
(submission.status === 'submitted' && submission.has_registrations);
const canDecide =
canReview && submission.status === 'submitted' && !notYetSubmitted;
const canReviewersSign =
submission.status === 'submitted' && !notYetSubmitted;
const missingDepartmentLeaderSignature =
!submission.department_leader_signature_url;
const missingOwnSignature =
(canSignAsDepartmentLeader &&
!submission.department_leader_signature_url) ||
(canSignAsAdvisor && !submission.advisor_signature_url);
const cannotApprove = missingDepartmentLeaderSignature || missingOwnSignature;
const totalCredits = submission.course_registrations.reduce(
(sum, registration) =>
submission.has_registrations
? registration.is_registered
? sum + registration.course.credits
: sum
: isSemesterOpen && registration.course_class
? sum + registration.course.credits
: sum,
0,
);
function handleApprove() {
router.patch(approve.url(submission.id), {}, { preserveScroll: true });
}
return (
<Form
action={update([studentId, semesterKey(semester)])}
options={{ preserveScroll: true }}
className="flex flex-col gap-6"
>
{({ processing, errors }) => (
<>
<Card>
<CardContent className="p-6 sm:p-8">
<div className="flex flex-col items-center gap-1 text-center">
<h2 className="text-lg font-bold tracking-wide uppercase">
Kartu Rencana Studi
</h2>
<Badge
variant={
notYetSubmitted
? 'secondary'
: RegistrationStatusVariants[
submission.status
]
}
>
{notYetSubmitted
? 'Belum Diajukan'
: RegistrationStatusLabels[
submission.status
]}
</Badge>
</div>
<div className="mt-6 flex flex-col justify-between gap-4 sm:flex-row">
<div className="grid gap-1">
<InfoRow
label="Nama"
value={
student?.user?.profile?.full_name ??
'N/A'
}
/>
<InfoRow
label="NIM"
value={student?.student_number ?? '-'}
/>
</div>
<div className="grid gap-1 sm:text-right">
<InfoRow
label="Program Studi"
value={department?.name ?? '-'}
/>
<InfoRow
label="Dosen Pembimbing Akademik"
value={
student?.academic_advisor?.user
?.profile?.full_name ?? '-'
}
/>
</div>
</div>
<div className="mt-6 overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
No
</TableHead>
<TableHead>Mata Kuliah</TableHead>
<TableHead>Kode</TableHead>
<TableHead className="text-center">
SKS
</TableHead>
<TableHead className="w-[40px] text-center">
Ambil
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submission.course_registrations
.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground"
>
Belum ada mata kuliah yang
dipilih.
</TableCell>
</TableRow>
) : (
submission.course_registrations.map(
(
registration: CourseRegistrationSubmissionEntry,
index,
) => {
const course =
registration.course;
const checked =
submission.has_registrations
? registration.is_registered
: isSemesterOpen;
return (
<TableRow
key={
registration.id
}
>
<TableCell>
{index + 1}
</TableCell>
<TableCell>
{course.name}
</TableCell>
<TableCell>
{course.code}
</TableCell>
<TableCell className="text-center">
{
course.credits
}
</TableCell>
<TableCell className="text-center">
{registration.course_class ? (
<Checkbox
name="course_class_ids[]"
value={
registration
.course_class
.id
}
defaultChecked={
checked
}
disabled={
!isSemesterOpen ||
isViewOnly ||
isLocked
}
/>
) : (
<span
className="text-xs text-muted-foreground"
title="Kelas belum dibuka untuk periode ini"
>
</span>
)}
</TableCell>
</TableRow>
);
},
)
)}
</TableBody>
{submission.course_registrations.length >
0 && (
<TableFooter>
<TableRow>
<TableCell
colSpan={3}
className="text-right font-medium"
>
Total SKS
</TableCell>
<TableCell className="text-center font-medium">
{totalCredits}
</TableCell>
<TableCell />
</TableRow>
</TableFooter>
)}
</Table>
</div>
<div className="mt-10 grid gap-6">
<div className="flex flex-col gap-8 sm:flex-row sm:justify-around">
<div className="grid gap-1 text-sm">
<p>Menyetujui,</p>
<p>Ketua Program Studi,</p>
<ReviewerSignatureField
signatureUrl={
submission.department_leader_signature_url
}
alt="Tanda tangan Ketua Program Studi"
canSign={
canSignAsDepartmentLeader &&
canReviewersSign
}
submitUrl={department_leader_signature.url(
[
studentId,
semesterKey(semester),
],
)}
/>
<p className="font-semibold">
{departmentLeader?.user?.profile
?.full_name ?? '-'}
</p>
<p className="text-xs text-muted-foreground">
NIDN.{' '}
{departmentLeader?.lecturer_number ??
'-'}
</p>
</div>
<div className="grid gap-1 text-sm">
<p>Disetujui oleh,</p>
<p>Pembimbing Akademik,</p>
<ReviewerSignatureField
signatureUrl={
submission.advisor_signature_url
}
alt="Tanda tangan Pembimbing Akademik"
canSign={
canSignAsAdvisor &&
canReviewersSign
}
submitUrl={advisor_signature.url([
studentId,
semesterKey(semester),
])}
/>
<p className="font-semibold">
{advisor?.user?.profile
?.full_name ?? '-'}
</p>
<p className="text-xs text-muted-foreground">
NIDN.{' '}
{advisor?.lecturer_number ?? '-'}
</p>
</div>
<div className="grid gap-1 text-sm">
<p>Mahasiswa,</p>
<SignatureBox
signatureUrl={
submission.signature_url
}
alt="Tanda tangan mahasiswa"
error={errors.signature}
editable={
isSemesterOpen &&
!isViewOnly &&
!isLocked
}
/>
<p className="font-semibold">
{student?.user?.profile
?.full_name ?? 'N/A'}
</p>
<p className="text-xs text-muted-foreground">
NIM.{' '}
{student?.student_number ?? '-'}
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{!backHref && !isLocked && isSemesterOpen && (
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
<Send className="h-4 w-4" />
{processing ? 'Mengajukan...' : 'Ajukan'}
</Button>
</div>
)}
{canDecide && (
<div className="flex flex-col items-end gap-2">
<div className="flex gap-2">
<Button
type="button"
variant="destructive"
onClick={() => setRejecting(true)}
>
<X className="h-4 w-4" />
Tolak
</Button>
<Button
type="button"
disabled={cannotApprove}
onClick={handleApprove}
>
<Check className="h-4 w-4" />
Setujui
</Button>
</div>
{cannotApprove && (
<p className="text-xs text-muted-foreground">
{missingDepartmentLeaderSignature
? 'Menunggu tanda tangan Ketua Program Studi sebelum dapat disetujui.'
: 'Silakan tanda tangani KRS ini terlebih dahulu sebelum menyetujui.'}
</p>
)}
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Riwayat</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
{submission.logs.map((log, index) => (
<LogBubble
key={log.id}
log={log}
isResubmission={
log.status === 'submitted' &&
submission.logs
.slice(0, index)
.some(
(l) => l.status === 'submitted',
)
}
/>
))}
</CardContent>
</Card>
<RejectDialog
open={rejecting}
onOpenChange={setRejecting}
submissionId={submission.id}
/>
</>
)}
</Form>
);
}
type Props = {
submissions: Record<string, CourseRegistrationSubmission>;
semesters: (number | null)[];
openSemesters: number[];
studentCurrentSemester: number;
hasActiveTerm: boolean;
backHref: string | null;
canReview: boolean;
canSignAsDepartmentLeader: boolean;
canSignAsAdvisor: boolean;
};
export default function CourseRegistrationDetail({
submissions,
semesters,
openSemesters,
studentCurrentSemester,
hasActiveTerm,
backHref,
canReview,
canSignAsDepartmentLeader,
canSignAsAdvisor,
}: Props) {
const [activeSemester, setActiveSemester] = useState(() =>
semesterKey(semesters[0] ?? null),
);
const currentKey = semesters.some(
(semester) => semesterKey(semester) === activeSemester,
)
? activeSemester
: semesterKey(semesters[0] ?? null);
const currentSemester =
semesters.find((semester) => semesterKey(semester) === currentKey) ??
null;
const currentSubmission = submissions[currentKey];
const studentId = Object.values(submissions)[0]?.student?.id ?? 0;
return (
<>
<Head title="Detail Registrasi KRS" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Detail Registrasi KRS"
actions={
backHref && (
<Button variant="outline" asChild>
<Link href={backHref}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
)
}
/>
{semesters.length > 0 && (
<Tabs value={currentKey} onValueChange={setActiveSemester}>
<TabsList>
{semesters.map((semester) => (
<TabsTrigger
key={semesterKey(semester)}
value={semesterKey(semester)}
>
{semester !== null
? `Semester ${semester}`
: 'Lainnya'}
{!isSemesterOpenForStudent(
semester,
openSemesters,
studentCurrentSemester,
) && ' (Ditutup)'}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
{currentSubmission ? (
<SemesterCard
key={currentKey}
semester={currentSemester}
submission={currentSubmission}
studentId={studentId}
isSemesterOpen={isSemesterOpenForStudent(
currentSemester,
openSemesters,
studentCurrentSemester,
)}
backHref={backHref}
canReview={canReview}
canSignAsDepartmentLeader={canSignAsDepartmentLeader}
canSignAsAdvisor={canSignAsAdvisor}
/>
) : (
semesters.length > 0 &&
!hasActiveTerm && (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground" />
<p className="font-medium">
Belum ada periode akademik aktif
</p>
<p className="max-w-sm text-sm text-muted-foreground">
Registrasi KRS hanya dapat dilakukan saat
ada periode akademik yang aktif. Silakan
hubungi admin untuk informasi lebih
lanjut.
</p>
</CardContent>
</Card>
)
)}
{semesters.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground" />
<p className="font-medium">
Belum ada mata kuliah terdaftar
</p>
<p className="max-w-sm text-sm text-muted-foreground">
Belum ada mata kuliah yang terdaftar untuk
jurusan ini. Silakan hubungi admin atau
program studi.
</p>
</CardContent>
</Card>
)}
</div>
</>
);
}