- Added a new column to display open semesters in the academic terms table with badges. - Removed course registration related pages and components including index, create, and show. - Updated course registration types to reflect changes in the data structure. - Refactored admin routes for course registrations to streamline submission handling.
692 lines
29 KiB
TypeScript
692 lines
29 KiB
TypeScript
import { Form, Head, Link, router } from '@inertiajs/react';
|
|
import { format } from 'date-fns';
|
|
import { ArrowLeft, Check, 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,
|
|
kaprodi_signature,
|
|
reject,
|
|
save,
|
|
} 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 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,
|
|
canSignAsKaprodi,
|
|
canSignAsAdvisor,
|
|
}: {
|
|
semester: number | null;
|
|
submission: CourseRegistrationSubmission;
|
|
studentId: number;
|
|
isSemesterOpen: boolean;
|
|
backHref: string | null;
|
|
canReview: boolean;
|
|
canSignAsKaprodi: boolean;
|
|
canSignAsAdvisor: boolean;
|
|
}) {
|
|
const [rejecting, setRejecting] = useState(false);
|
|
|
|
const student = submission.student;
|
|
const department = student?.department ?? null;
|
|
const kaprodi = 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 missingKaprodiSignature = !submission.kaprodi_signature_url;
|
|
const missingOwnSignature =
|
|
(canSignAsKaprodi && !submission.kaprodi_signature_url) ||
|
|
(canSignAsAdvisor && !submission.advisor_signature_url);
|
|
const cannotApprove = missingKaprodiSignature || missingOwnSignature;
|
|
|
|
const totalCredits = submission.course_registrations.reduce(
|
|
(sum, registration) =>
|
|
submission.has_registrations
|
|
? registration.is_registered
|
|
? sum + (registration.course_class?.course?.credits ?? 0)
|
|
: sum
|
|
: isSemesterOpen
|
|
? sum + (registration.course_class?.course?.credits ?? 0)
|
|
: sum,
|
|
0,
|
|
);
|
|
|
|
function handleApprove() {
|
|
router.patch(approve.url(submission.id), {}, { preserveScroll: true });
|
|
}
|
|
|
|
return (
|
|
<Form
|
|
action={save([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_class
|
|
?.course;
|
|
const checked =
|
|
submission.has_registrations
|
|
? registration.is_registered
|
|
: isSemesterOpen;
|
|
|
|
return (
|
|
<TableRow
|
|
key={
|
|
registration.id
|
|
}
|
|
>
|
|
<TableCell>
|
|
{index + 1}
|
|
</TableCell>
|
|
<TableCell>
|
|
{course?.name ??
|
|
'N/A'}
|
|
</TableCell>
|
|
<TableCell>
|
|
{course?.code ??
|
|
'-'}
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
{course?.credits ??
|
|
'-'}
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Checkbox
|
|
name="course_class_ids[]"
|
|
value={
|
|
registration
|
|
.course_class
|
|
?.id
|
|
}
|
|
defaultChecked={
|
|
checked
|
|
}
|
|
disabled={
|
|
!isSemesterOpen ||
|
|
isViewOnly ||
|
|
isLocked
|
|
}
|
|
/>
|
|
</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.kaprodi_signature_url
|
|
}
|
|
alt="Tanda tangan Ketua Program Studi"
|
|
canSign={
|
|
canSignAsKaprodi &&
|
|
canReviewersSign
|
|
}
|
|
submitUrl={kaprodi_signature.url([
|
|
studentId,
|
|
semesterKey(semester),
|
|
])}
|
|
/>
|
|
<p className="font-semibold">
|
|
{kaprodi?.user?.profile
|
|
?.full_name ?? '-'}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
NIDN.{' '}
|
|
{kaprodi?.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={!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">
|
|
{missingKaprodiSignature
|
|
? '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[];
|
|
backHref: string | null;
|
|
canReview: boolean;
|
|
canSignAsKaprodi: boolean;
|
|
canSignAsAdvisor: boolean;
|
|
};
|
|
|
|
export default function CourseRegistrationDetail({
|
|
submissions,
|
|
semesters,
|
|
openSemesters,
|
|
backHref,
|
|
canReview,
|
|
canSignAsKaprodi,
|
|
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'}
|
|
{!openSemesters.includes(semester ?? -1) &&
|
|
' (Ditutup)'}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
)}
|
|
|
|
{currentSubmission && (
|
|
<SemesterCard
|
|
key={currentKey}
|
|
semester={currentSemester}
|
|
submission={currentSubmission}
|
|
studentId={studentId}
|
|
isSemesterOpen={openSemesters.includes(
|
|
currentSemester ?? -1,
|
|
)}
|
|
backHref={backHref}
|
|
canReview={canReview}
|
|
canSignAsKaprodi={canSignAsKaprodi}
|
|
canSignAsAdvisor={canSignAsAdvisor}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|