feat: enhance assignment management; implement new card and column components, improve pagination and filtering, and add lecturer information

This commit is contained in:
Yoga Pangestu 2026-09-04 14:13:09 +07:00
parent 35a64c2045
commit 56d607a131
7 changed files with 552 additions and 348 deletions

View File

@ -23,23 +23,15 @@ public function __construct(
public function index(PaginatedRequest $request): Response
{
$academicTermId = $request->has('academic_term_id')
? $request->validated('academic_term_id')
: $this->academicTermService->getActive()?->id;
return Inertia::render('admin/academic-classes/assignments/index', [
'assignments' => Inertia::scroll(fn () => $this->service->paginated(
'assignments' => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(),
courseClassId: $request->validated('course_class_id'),
academicTermId: $academicTermId,
)),
academicTermId: $this->academicTermService->getActive()?->id,
),
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
'academicTerms' => $this->academicTermService->getAllForSelect(),
'filters' => [
'course_class_id' => $request->validated('course_class_id'),
'academic_term_id' => $academicTermId ? (string) $academicTermId : null,
],
'filters' => $request->only(['course_class_id']),
]);
}

View File

@ -21,13 +21,16 @@ public function __construct(
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null, ?int $academicTermId = null): LengthAwarePaginator
{
return Assignment::query()
->select(['id', 'course_class_id', 'title', 'description', 'deadline', 'status'])
->withCount([
'submissions',
'submissions as graded_submissions_count' => fn ($q) => $q->whereNotNull('score'),
])
->with(['courseClass' => fn ($q) => $q->withCount('enrollments')
->with(['course:id,code,name', 'academicTerm:id,academic_year,semester,start_date,end_date'])])
->with([
'course:id,code,name',
'academicTerm:id,academic_year,semester,start_date,end_date',
'lecturer.user.profile',
])])
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->with(['submissions' => function ($q) use ($user) {
$q->select(['id', 'assignment_id', 'student_id', 'notes', 'status', 'submitted_at'])
->where('student_id', $user->student?->id);
@ -43,7 +46,7 @@ public function paginated(User $user, int $perPage = 25, string $search = '', ?i
});
}))
->latest()
->paginate($perPage);
->paginate($perPage,['id', 'course_class_id', 'title', 'description', 'deadline', 'status']);
}
public function create(array $data, ?UploadedFile $file): Assignment

View File

@ -0,0 +1,225 @@
import { format } from 'date-fns';
import {
Clock,
ClipboardList,
Pencil,
Trash2,
Upload,
User,
} from 'lucide-react';
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
import { RowActions } from '@/components/row-actions';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Badge } from '@/components/ui/badge';
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { Assignment } from '@/types/assignment';
import { AssignmentStatusLabels } from '@/types/assignment';
import { deadlineTextClass, percentageOf } from './utils';
type CreateCardParams = {
handleEdit: (assignment: Assignment) => void;
handleDeleteClick: (assignment: Assignment) => void;
handleSubmitClick: (assignment: Assignment) => void;
canUpdate: boolean;
canDelete: boolean;
canSubmit: boolean;
canViewSubmissions: boolean;
};
export function createAssignmentCard(params: CreateCardParams) {
const {
handleEdit,
handleDeleteClick,
handleSubmitClick,
canUpdate,
canDelete,
canSubmit,
canViewSubmissions,
} = params;
return function AssignmentCard(assignment: Assignment) {
const mySubmission = assignment.submissions?.[0];
const lecturerName =
assignment.course_class?.lecturer?.user?.profile?.full_name ??
null;
return (
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-base leading-tight font-medium">
{assignment.title}
</p>
{assignment.course_class && (
<span className="text-xs text-muted-foreground">
{assignment.course_class.course?.code ?? ''}{' '}
{assignment.course_class.course?.name ?? ''}
</span>
)}
{lecturerName && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<User className="h-3 w-3 shrink-0" />
{lecturerName}
</span>
)}
</div>
<RowActions
wrapperClassName="-mt-1 -mr-2 flex items-center gap-1"
actions={[
{
label:
mySubmission?.status === 'submitted'
? 'Kumpulkan Ulang'
: 'Kumpulkan Tugas',
icon: <Upload className="h-3.5 w-3.5" />,
show:
canSubmit && assignment.status === 'open',
onClick: () => handleSubmitClick(assignment),
},
{
label: 'Pengumpulan',
icon: (
<ClipboardList className="h-3.5 w-3.5" />
),
show: canViewSubmissions,
href: submissionsIndex.url(assignment.id),
},
{
label: 'Edit',
icon: <Pencil className="h-3.5 w-3.5" />,
show: canUpdate,
onClick: () => handleEdit(assignment),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-3.5 w-3.5 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(assignment),
},
]}
/>
</div>
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<Badge
variant={
assignment.status === 'open'
? 'secondary'
: 'destructive'
}
className="w-fit text-[10px] font-normal"
>
{AssignmentStatusLabels[assignment.status]}
</Badge>
{assignment.course_class?.academic_term && (
<Badge
variant="outline"
className="w-fit text-[10px] font-normal"
>
{formatAcademicTermLabel(
assignment.course_class.academic_term,
)}
</Badge>
)}
</div>
<span
className={`inline-flex items-center gap-1.5 text-xs ${deadlineTextClass(assignment.deadline)}`}
>
<Clock className="h-3.5 w-3.5 shrink-0" />
{format(new Date(assignment.deadline), 'd MMM yyyy, HH:mm')}
</span>
{assignment.attachment_url && assignment.attachment_name ? (
<div className="text-xs text-muted-foreground">
<AttachmentPreviewDialog
fileUrl={assignment.attachment_url}
fileName={assignment.attachment_name}
/>
</div>
) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{canSubmit ? (
<Badge
variant={
mySubmission?.status === 'submitted'
? 'default'
: 'secondary'
}
>
{mySubmission?.status === 'submitted'
? 'Sudah Mengumpulkan'
: 'Belum Mengumpulkan'}
</Badge>
) : (
<>
<Badge variant="secondary">
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
/{' '}
{(
assignment.course_class
?.enrollments_count ?? 0
).toLocaleString('id-ID')}{' '}
Pengumpulan (
{percentageOf(
assignment.submissions_count,
assignment.course_class
?.enrollments_count ?? 0,
)}
%)
</Badge>
<Badge variant="outline">
{assignment.graded_submissions_count.toLocaleString(
'id-ID',
)}{' '}
/{' '}
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
Dinilai (
{percentageOf(
assignment.graded_submissions_count,
assignment.submissions_count,
)}
%)
</Badge>
</>
)}
</div>
{assignment.description && (
<Accordion
type="single"
collapsible
className="-mx-6 -mb-6 border-t"
>
<AccordionItem
value="description"
className="border-b-0"
>
<AccordionTrigger className="px-6 text-xs font-medium text-foreground hover:no-underline">
Deskripsi
</AccordionTrigger>
<AccordionContent className="px-6">
<p className="text-sm whitespace-pre-line text-foreground">
{assignment.description}
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
);
};
}

View File

@ -0,0 +1,213 @@
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { ClipboardList, Pencil, Trash2, Upload } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
import type { Assignment } from '@/types/assignment';
import { AssignmentStatusLabels } from '@/types/assignment';
import { deadlineTextClass, percentageOf } from './utils';
export type { Assignment } from '@/types/assignment';
type CreateColumnsParams = {
handleEdit: (assignment: Assignment) => void;
handleDeleteClick: (assignment: Assignment) => void;
handleSubmitClick: (assignment: Assignment) => void;
canUpdate: boolean;
canDelete: boolean;
canSubmit: boolean;
canViewSubmissions: boolean;
};
export function createAssignmentColumns(
params: CreateColumnsParams,
): ColumnDef<Assignment>[] {
const {
handleEdit,
handleDeleteClick,
handleSubmitClick,
canUpdate,
canDelete,
canSubmit,
canViewSubmissions,
} = params;
const columns: ColumnDef<Assignment>[] = [
{
accessorKey: 'title',
header: () => <span>Judul</span>,
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('title') as string}
</span>
),
},
{
accessorKey: 'course_class.course.name',
header: () => <span>Kelas</span>,
cell: ({ row }) => {
const courseClass = row.original.course_class;
if (!courseClass) {
return '-';
}
return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`;
},
},
{
accessorKey: 'deadline',
header: () => <span>Batas Waktu</span>,
cell: ({ row }) => {
const assignment = row.original;
return (
<span className={deadlineTextClass(assignment.deadline)}>
{format(
new Date(assignment.deadline),
'd MMM yyyy, HH:mm',
)}
</span>
);
},
},
{
accessorKey: 'status',
header: () => <span className="block text-center">Status</span>,
meta: {
className: 'text-center',
headerClassName: 'text-center',
},
cell: ({ row }) => {
const assignment = row.original;
return (
<Badge
variant={
assignment.status === 'open'
? 'secondary'
: 'destructive'
}
>
{AssignmentStatusLabels[assignment.status]}
</Badge>
);
},
},
{
id: 'submissions',
header: () => <span>Pengumpulan</span>,
cell: ({ row }) => {
const assignment = row.original;
if (canSubmit) {
const mySubmission = assignment.submissions?.[0];
return (
<Badge
variant={
mySubmission?.status === 'submitted'
? 'default'
: 'secondary'
}
>
{mySubmission?.status === 'submitted'
? 'Sudah Mengumpulkan'
: 'Belum Mengumpulkan'}
</Badge>
);
}
const enrollmentsCount =
assignment.course_class?.enrollments_count ?? 0;
return (
<div className="flex flex-col gap-0.5 text-xs text-muted-foreground">
<span>
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
/ {enrollmentsCount.toLocaleString('id-ID')}{' '}
mengumpulkan (
{percentageOf(
assignment.submissions_count,
enrollmentsCount,
)}
%)
</span>
<span>
{assignment.graded_submissions_count.toLocaleString(
'id-ID',
)}{' '}
/{' '}
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
dinilai (
{percentageOf(
assignment.graded_submissions_count,
assignment.submissions_count,
)}
%)
</span>
</div>
);
},
},
];
columns.push({
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[160px] text-center',
headerClassName: 'w-[160px] text-center',
},
cell: ({ row }) => {
const assignment = row.original;
const mySubmission = assignment.submissions?.[0];
return (
<div className="flex items-center justify-center">
<RowActions
actions={[
{
label:
mySubmission?.status === 'submitted'
? 'Kumpulkan Ulang'
: 'Kumpulkan Tugas',
icon: <Upload className="h-4 w-4" />,
show:
canSubmit && assignment.status === 'open',
onClick: () => handleSubmitClick(assignment),
},
{
label: 'Pengumpulan',
icon: <ClipboardList className="h-4 w-4" />,
show: canViewSubmissions,
href: submissionsIndex.url(assignment.id),
},
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(assignment),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(assignment),
},
]}
/>
</div>
);
},
});
return columns;
}

View File

@ -1,37 +1,18 @@
import { Head, InfiniteScroll, router } from '@inertiajs/react';
import { format } from 'date-fns';
import {
Clock,
ClipboardList,
Paperclip,
Pencil,
Plus,
Trash2,
Upload,
} from 'lucide-react';
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
import { DataCards } from '@/components/data-cards';
import type { PaginationState } from '@/components/data-table';
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 type {
FilterField,
FilterOptionGroup,
} from '@/components/filter-dialog';
import type { FilterOptionGroup } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
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 {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Combobox,
ComboboxCollection,
@ -53,6 +34,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import type { ViewMode } from '@/components/view-toggle';
import { ViewToggle } from '@/components/view-toggle';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
@ -62,10 +45,10 @@ import {
submit,
update,
} from '@/routes/admin/academic-classes/assignments';
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { Assignment } from '@/types/assignment';
import { AssignmentStatusLabels, AssignmentStatuses } from '@/types/assignment';
import { AssignmentStatuses, AssignmentStatusLabels } from '@/types/assignment';
import { createAssignmentCard } from './card';
import { createAssignmentColumns } from './columns';
type CourseClassOption = {
id: number;
@ -80,12 +63,6 @@ type CourseClassOption = {
type CourseClassGroup = { value: string; items: CourseClassOption[] };
type AcademicTermOption = {
id: number;
academic_year: string;
semester: string;
};
type Props = {
assignments: {
data: Assignment[];
@ -95,11 +72,9 @@ type Props = {
total: number;
};
courseClasses: CourseClassOption[];
academicTerms: AcademicTermOption[];
highlight?: number;
filters: {
course_class_id?: string;
academic_term_id?: string;
};
};
@ -107,29 +82,6 @@ function courseClassLabel(courseClass: CourseClassOption): string {
return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`;
}
function percentageOf(part: number, total: number): number {
return total > 0 ? Math.round((part / total) * 100) : 0;
}
/** Turns red as the deadline passes, amber once it's within 3 days. */
function deadlineTextClass(deadline: string): string {
const hoursLeft = (new Date(deadline).getTime() - Date.now()) / 3_600_000;
if (hoursLeft <= 0) {
return 'font-medium text-destructive';
}
if (hoursLeft <= 24) {
return 'text-destructive';
}
if (hoursLeft <= 72) {
return 'text-amber-600 dark:text-amber-500';
}
return 'text-muted-foreground';
}
function groupCourseClassesByDepartment(
options: CourseClassOption[],
): CourseClassGroup[] {
@ -209,7 +161,6 @@ function CourseClassField({
export default function AssignmentIndex({
assignments,
courseClasses,
academicTerms,
highlight,
filters,
}: Props) {
@ -217,6 +168,7 @@ export default function AssignmentIndex({
const [editing, setEditing] = useState<Assignment | null>(null);
const [deleting, setDeleting] = useState<Assignment | null>(null);
const [submitting, setSubmitting] = useState<Assignment | null>(null);
const [view, setView] = useState<ViewMode>('table');
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-assignments');
const canUpdate = hasPermission('update-assignments');
@ -224,15 +176,7 @@ export default function AssignmentIndex({
const canViewSubmissions = hasPermission('view-assignment-submissions');
const canSubmit = hasPermission('submit-assignments');
const filterFields: FilterField[] = [
{
key: 'academic_term_id',
label: 'Periode Akademik',
options: academicTerms.map((term) => ({
value: String(term.id),
label: formatAcademicTermLabel(term),
})),
},
const filterFields = [
{
key: 'course_class_id',
label: 'Kelas',
@ -241,33 +185,25 @@ export default function AssignmentIndex({
},
];
const pagination = {
const pagination: PaginationState = {
current_page: assignments.current_page,
last_page: assignments.last_page,
per_page: assignments.per_page,
total: assignments.total,
};
const { search, handleSearchChange, applyFilters } = useServerTable({
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => assignmentIndex.url(),
pagination,
filters,
resetKeys: ['assignments'],
});
function handleApplyFilters(newFilters: Record<string, string>) {
// Tanpa `academic_term_id` eksplisit, backend akan kembali ke
// periode aktif, jadi menghapus filter ini perlu dikirim eksplisit
// alih-alih hanya menghilangkan key-nya.
const clearedAcademicTerm =
Boolean(filters.academic_term_id) && !newFilters.academic_term_id;
applyFilters({
...newFilters,
...(clearedAcademicTerm ? { academic_term_id: '' } : {}),
});
}
function handleDelete() {
if (!deleting) {
return;
@ -278,6 +214,37 @@ export default function AssignmentIndex({
});
}
const columns = createAssignmentColumns({
handleEdit: (assignment) => setEditing(assignment),
handleDeleteClick: (assignment) => setDeleting(assignment),
handleSubmitClick: (assignment) => setSubmitting(assignment),
canUpdate,
canDelete,
canSubmit,
canViewSubmissions,
});
const renderAssignmentCard = createAssignmentCard({
handleEdit: (assignment) => setEditing(assignment),
handleDeleteClick: (assignment) => setDeleting(assignment),
handleSubmitClick: (assignment) => setSubmitting(assignment),
canUpdate,
canDelete,
canSubmit,
canViewSubmissions,
});
const toolbar = (
<>
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
<ViewToggle value={view} onChange={setView} />
</>
);
return (
<>
<Head title="Tugas" />
@ -351,256 +318,31 @@ export default function AssignmentIndex({
assignment={submitting}
/>
<div className="flex items-center gap-2">
<Input
placeholder="Cari judul tugas..."
value={search}
onChange={(event) =>
handleSearchChange(event.target.value)
}
className="max-w-sm"
{view === 'table' ? (
<DataTable
columns={columns}
data={assignments.data}
searchKey="title"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={toolbar}
/>
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={handleApplyFilters}
/>
</div>
{assignments.data.length === 0 ? (
<p className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
Belum ada tugas.
</p>
) : (
<InfiniteScroll
data="assignments"
as="div"
buffer={300}
className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"
loading={() => (
<p className="col-span-full py-4 text-center text-sm text-muted-foreground">
Memuat tugas...
</p>
)}
>
{assignments.data.map((assignment) => {
const mySubmission = assignment.submissions?.[0];
return (
<Card
key={assignment.id}
className={
highlight === assignment.id
? 'ring-2 ring-primary'
: undefined
}
>
<CardHeader className="flex flex-row items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-base leading-tight">
{assignment.title}
</CardTitle>
{assignment.course_class && (
<span className="text-xs text-muted-foreground">
{assignment.course_class
.course?.code ??
''}{' '}
{assignment.course_class
.course?.name ?? ''}
</span>
)}
</div>
<RowActions
wrapperClassName="-mt-1 -mr-2 flex items-center gap-1"
actions={[
{
label:
mySubmission?.status ===
'submitted'
? 'Kumpulkan Ulang'
: 'Kumpulkan Tugas',
icon: (
<Upload className="h-3.5 w-3.5" />
),
show:
canSubmit &&
assignment.status ===
'open',
onClick: () =>
setSubmitting(
assignment,
),
},
{
label: 'Pengumpulan',
icon: (
<ClipboardList className="h-3.5 w-3.5" />
),
show: canViewSubmissions,
href: submissionsIndex.url(
assignment.id,
),
},
{
label: 'Edit',
icon: (
<Pencil className="h-3.5 w-3.5" />
),
show: canUpdate,
onClick: () =>
setEditing(assignment),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-3.5 w-3.5 text-destructive" />
),
show: canDelete,
onClick: () =>
setDeleting(assignment),
},
]}
/>
</CardHeader>
<CardContent className="flex flex-col gap-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant={
assignment.status === 'open'
? 'secondary'
: 'destructive'
}
className="w-fit text-[10px] font-normal"
>
{
AssignmentStatusLabels[
assignment.status
]
}
</Badge>
{assignment.course_class
?.academic_term && (
<Badge
variant="outline"
className="w-fit text-[10px] font-normal"
>
{formatAcademicTermLabel(
assignment.course_class
.academic_term,
)}
</Badge>
)}
</div>
<span
className={`inline-flex items-center gap-1.5 ${deadlineTextClass(assignment.deadline)}`}
>
<Clock className="h-3.5 w-3.5 shrink-0" />
{format(
new Date(assignment.deadline),
'd MMM yyyy, HH:mm',
)}
</span>
{assignment.attachment_url &&
assignment.attachment_name ? (
<div className="flex items-center gap-1.5">
<Paperclip className="h-3.5 w-3.5 shrink-0" />
<AttachmentPreviewDialog
fileUrl={
assignment.attachment_url
}
fileName={
assignment.attachment_name
}
/>
</div>
) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{canSubmit ? (
<Badge
variant={
mySubmission?.status ===
'submitted'
? 'default'
: 'secondary'
}
>
{mySubmission?.status ===
'submitted'
? 'Sudah Mengumpulkan'
: 'Belum Mengumpulkan'}
</Badge>
) : (
<>
<Badge variant="secondary">
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
/{' '}
{(
assignment
.course_class
?.enrollments_count ??
0
).toLocaleString(
'id-ID',
)}{' '}
Pengumpulan (
{percentageOf(
assignment.submissions_count,
assignment
.course_class
?.enrollments_count ??
0,
)}
%)
</Badge>
<Badge variant="outline">
{assignment.graded_submissions_count.toLocaleString(
'id-ID',
)}{' '}
/{' '}
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
Dinilai (
{percentageOf(
assignment.graded_submissions_count,
assignment.submissions_count,
)}
%)
</Badge>
</>
)}
</div>
{assignment.description && (
<Accordion
type="single"
collapsible
className="-mx-6 -mb-6 border-t"
>
<AccordionItem
value="description"
className="border-b-0"
>
<AccordionTrigger className="px-6 text-xs font-medium text-foreground hover:no-underline">
Deskripsi
</AccordionTrigger>
<AccordionContent className="px-6">
<p className="text-sm whitespace-pre-line text-foreground">
{
assignment.description
}
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</CardContent>
</Card>
);
})}
</InfiniteScroll>
<DataCards
data={assignments.data}
renderCard={renderAssignmentCard}
getRowId={(assignment) => assignment.id}
searchKey="title"
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
toolbar={toolbar}
/>
)}
<DeleteConfirmDialog

View File

@ -0,0 +1,22 @@
export function percentageOf(part: number, total: number): number {
return total > 0 ? Math.round((part / total) * 100) : 0;
}
/** Turns red as the deadline passes, amber once it's within 3 days. */
export function deadlineTextClass(deadline: string): string {
const hoursLeft = (new Date(deadline).getTime() - Date.now()) / 3_600_000;
if (hoursLeft <= 0) {
return 'font-medium text-destructive';
}
if (hoursLeft <= 24) {
return 'text-destructive';
}
if (hoursLeft <= 72) {
return 'text-amber-600 dark:text-amber-500';
}
return 'text-muted-foreground';
}

View File

@ -27,6 +27,13 @@ export type Assignment = {
academic_year: string;
semester: string;
} | null;
lecturer: {
id: number;
user: {
id: number;
profile: { full_name: string } | null;
} | null;
} | null;
/** Total students enrolled (approved) in this class. */
enrollments_count: number;
} | null;