- 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.
433 lines
16 KiB
TypeScript
433 lines
16 KiB
TypeScript
import { Head, Link, router } from '@inertiajs/react';
|
|
import type { ColumnDef } from '@tanstack/react-table';
|
|
import { format } from 'date-fns';
|
|
import { ArrowLeft, Pencil, Plus, Trash2 } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { DataTable } from '@/components/data-table';
|
|
import { DateTimeField } from '@/components/datetime-field';
|
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
|
import { FileUploadField } from '@/components/file-upload-field';
|
|
import { FormDialog } from '@/components/form-dialog';
|
|
import InputError from '@/components/input-error';
|
|
import { PageHeader } from '@/components/page-header';
|
|
import { RowActions } from '@/components/row-actions';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { usePermissions } from '@/hooks/use-permissions';
|
|
import { index as assignmentIndex } from '@/routes/admin/academic-classes/assignments';
|
|
import {
|
|
destroy,
|
|
store,
|
|
update,
|
|
} from '@/routes/admin/academic-classes/assignments/submissions';
|
|
import type { Assignment } from '@/types/assignment';
|
|
import type { Submission, SubmissionStudent } from '@/types/submission';
|
|
import { SubmissionStatusLabels, SubmissionStatuses } from '@/types/submission';
|
|
|
|
type Props = {
|
|
assignment: Assignment;
|
|
submissions: Submission[];
|
|
availableStudents: SubmissionStudent[];
|
|
};
|
|
|
|
export default function SubmissionIndex({
|
|
assignment,
|
|
submissions,
|
|
availableStudents,
|
|
}: Props) {
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [editing, setEditing] = useState<Submission | null>(null);
|
|
const [deleting, setDeleting] = useState<Submission | null>(null);
|
|
const { hasPermission } = usePermissions();
|
|
const canCreate = hasPermission('create-assignment-submissions');
|
|
const canUpdate = hasPermission('update-assignment-submissions');
|
|
const canDelete = hasPermission('delete-assignment-submissions');
|
|
|
|
function handleDelete() {
|
|
if (!deleting) {
|
|
return;
|
|
}
|
|
|
|
router.delete(destroy([assignment.id, deleting.id]), {
|
|
onSuccess: () => setDeleting(null),
|
|
});
|
|
}
|
|
|
|
const columns: ColumnDef<Submission>[] = [
|
|
{
|
|
accessorKey: 'student.student_number',
|
|
header: () => <span>NIM</span>,
|
|
cell: ({ row }) => row.original.student?.student_number ?? '-',
|
|
},
|
|
{
|
|
accessorKey: 'student.user.profile.full_name',
|
|
header: () => <span>Nama Mahasiswa</span>,
|
|
cell: ({ row }) =>
|
|
row.original.student?.user?.profile?.full_name ?? '-',
|
|
},
|
|
{
|
|
accessorKey: 'status',
|
|
header: () => <span className="block text-center">Status</span>,
|
|
meta: {
|
|
className: 'w-[160px] text-center',
|
|
headerClassName: 'w-[160px] text-center',
|
|
},
|
|
cell: ({ row }) => {
|
|
const status = row.getValue('status') as
|
|
keyof typeof SubmissionStatusLabels | null;
|
|
|
|
return (
|
|
<div className="flex justify-center">
|
|
{status ? (
|
|
<Badge
|
|
variant={
|
|
status === 'submitted'
|
|
? 'default'
|
|
: 'secondary'
|
|
}
|
|
>
|
|
{SubmissionStatusLabels[status]}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground">-</span>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'submitted_at',
|
|
header: () => <span>Waktu Kumpul</span>,
|
|
cell: ({ row }) => {
|
|
const submittedAt = row.getValue('submitted_at') as
|
|
string | null;
|
|
|
|
return submittedAt
|
|
? format(new Date(submittedAt), 'd MMM yyyy, HH:mm')
|
|
: '-';
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'score',
|
|
header: () => <span className="block text-center">Nilai</span>,
|
|
meta: {
|
|
className: 'w-[90px] text-center',
|
|
headerClassName: 'w-[90px] text-center',
|
|
},
|
|
cell: ({ row }) => (
|
|
<div className="text-center">{row.original.score ?? '-'}</div>
|
|
),
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: () => <span className="block text-center">Aksi</span>,
|
|
meta: {
|
|
className: 'w-[100px] text-center',
|
|
headerClassName: 'w-[100px] text-center',
|
|
},
|
|
cell: ({ row }) => (
|
|
<RowActions
|
|
actions={[
|
|
{
|
|
label: 'Edit',
|
|
icon: <Pencil className="h-4 w-4" />,
|
|
show: canUpdate,
|
|
onClick: () => setEditing(row.original),
|
|
},
|
|
{
|
|
label: 'Hapus',
|
|
icon: (
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
),
|
|
show: canDelete,
|
|
onClick: () => setDeleting(row.original),
|
|
},
|
|
]}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<Head title="Pengumpulan Tugas" />
|
|
|
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
|
<PageHeader
|
|
title="Pengumpulan Tugas"
|
|
actions={
|
|
<Button variant="outline" asChild>
|
|
<Link href={assignmentIndex.url()}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Kembali
|
|
</Link>
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{assignment.title}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-1 text-sm text-muted-foreground">
|
|
<p>
|
|
Kelas: {assignment.course_class?.course?.code}{' '}
|
|
{assignment.course_class?.course?.name}
|
|
</p>
|
|
<p>
|
|
Batas Waktu:{' '}
|
|
{format(
|
|
new Date(assignment.deadline),
|
|
'd MMM yyyy, HH:mm',
|
|
)}
|
|
</p>
|
|
<p>Jumlah Pengumpulan: {submissions.length}</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold">
|
|
Daftar Pengumpulan
|
|
</h2>
|
|
{canCreate && (
|
|
<Button
|
|
onClick={() => setCreateOpen(true)}
|
|
disabled={availableStudents.length === 0}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Tambah Pengumpulan
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<DataTable columns={columns} data={submissions} />
|
|
|
|
<CreateForm
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
assignmentId={assignment.id}
|
|
availableStudents={availableStudents}
|
|
/>
|
|
|
|
<EditForm
|
|
key={editing?.id}
|
|
open={editing !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setEditing(null);
|
|
}
|
|
}}
|
|
assignmentId={assignment.id}
|
|
editing={editing}
|
|
/>
|
|
|
|
<DeleteConfirmDialog
|
|
target={deleting}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleting(null);
|
|
}
|
|
}}
|
|
title="Hapus Pengumpulan"
|
|
description={(submission) =>
|
|
`Apakah Anda yakin ingin menghapus pengumpulan "${submission.student?.user?.profile?.full_name ?? 'mahasiswa ini'}"? Tindakan ini tidak dapat dibatalkan.`
|
|
}
|
|
onConfirm={handleDelete}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CreateForm({
|
|
open,
|
|
onOpenChange,
|
|
assignmentId,
|
|
availableStudents,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
assignmentId: number;
|
|
availableStudents: SubmissionStudent[];
|
|
}) {
|
|
return (
|
|
<FormDialog
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title="Tambah Pengumpulan"
|
|
action={store(assignmentId)}
|
|
resetOnSuccess
|
|
onSuccess={() => onOpenChange(false)}
|
|
>
|
|
{({ errors }) => (
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<Label>
|
|
Mahasiswa{' '}
|
|
<span className="text-destructive">*</span>
|
|
</Label>
|
|
<input type="hidden" name="student_id" />
|
|
<Select name="student_id">
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Pilih mahasiswa" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableStudents.map((student) => (
|
|
<SelectItem
|
|
key={student.id}
|
|
value={String(student.id)}
|
|
>
|
|
{student.user?.profile?.full_name ??
|
|
'N/A'}{' '}
|
|
- {student.student_number}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError message={errors.student_id} />
|
|
</div>
|
|
<SubmissionFields
|
|
errors={errors}
|
|
resetKey={open ? 'open' : 'closed'}
|
|
/>
|
|
</div>
|
|
)}
|
|
</FormDialog>
|
|
);
|
|
}
|
|
|
|
function EditForm({
|
|
open,
|
|
onOpenChange,
|
|
assignmentId,
|
|
editing,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
assignmentId: number;
|
|
editing: Submission | null;
|
|
}) {
|
|
return (
|
|
<FormDialog
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title="Edit Pengumpulan"
|
|
action={editing ? update([assignmentId, editing.id]) : ''}
|
|
resetOnSuccess
|
|
onSuccess={() => onOpenChange(false)}
|
|
>
|
|
{({ errors }) =>
|
|
editing && (
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<Label>Mahasiswa</Label>
|
|
<p className="text-sm font-medium">
|
|
{editing.student?.user?.profile?.full_name ??
|
|
'N/A'}{' '}
|
|
- {editing.student?.student_number}
|
|
</p>
|
|
</div>
|
|
<SubmissionFields errors={errors} editing={editing} />
|
|
</div>
|
|
)
|
|
}
|
|
</FormDialog>
|
|
);
|
|
}
|
|
|
|
function SubmissionFields({
|
|
errors,
|
|
editing,
|
|
resetKey,
|
|
}: {
|
|
errors: Record<string, string>;
|
|
editing?: Submission;
|
|
resetKey?: string;
|
|
}) {
|
|
return (
|
|
<>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="notes">Catatan</Label>
|
|
<Textarea
|
|
id="notes"
|
|
name="notes"
|
|
placeholder="Catatan dari mahasiswa"
|
|
defaultValue={editing?.notes ?? ''}
|
|
/>
|
|
<InputError message={errors.notes} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>
|
|
Status <span className="text-destructive">*</span>
|
|
</Label>
|
|
<input type="hidden" name="status" />
|
|
<Select
|
|
name="status"
|
|
defaultValue={editing?.status ?? 'not_submitted'}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Pilih status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SubmissionStatuses.map((status) => (
|
|
<SelectItem key={status} value={status}>
|
|
{SubmissionStatusLabels[status]}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError message={errors.status} />
|
|
</div>
|
|
<DateTimeField
|
|
label="Waktu Kumpul"
|
|
name="submitted_at"
|
|
defaultValue={editing?.submitted_at}
|
|
placeholder="Pilih waktu kumpul"
|
|
error={errors.submitted_at}
|
|
/>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="score">Nilai</Label>
|
|
<Input
|
|
id="score"
|
|
name="score"
|
|
type="number"
|
|
min={0}
|
|
max={100}
|
|
step="0.01"
|
|
placeholder="0 - 100"
|
|
defaultValue={editing?.score ?? undefined}
|
|
/>
|
|
<InputError message={errors.score} />
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="lecturer_feedback">Feedback Dosen</Label>
|
|
<Textarea
|
|
id="lecturer_feedback"
|
|
name="lecturer_feedback"
|
|
placeholder="Masukkan feedback untuk mahasiswa"
|
|
defaultValue={editing?.lecturer_feedback ?? ''}
|
|
/>
|
|
<InputError message={errors.lecturer_feedback} />
|
|
</div>
|
|
<FileUploadField
|
|
key={resetKey}
|
|
label="File Pengumpulan"
|
|
existingFileName={editing?.file_name}
|
|
existingFileUrl={editing?.file_url}
|
|
error={errors.file}
|
|
/>
|
|
</>
|
|
);
|
|
}
|