feat: enhance assignment management; implement new card and column components, improve pagination and filtering, and add lecturer information
This commit is contained in:
parent
35a64c2045
commit
56d607a131
@ -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']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
225
resources/js/pages/admin/academic-classes/assignments/card.tsx
Normal file
225
resources/js/pages/admin/academic-classes/assignments/card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user