762 lines
32 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|