From 56d607a131d665c13273a3710eddcf4883f6e54b Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 4 Sep 2026 14:13:09 +0700 Subject: [PATCH] feat: enhance assignment management; implement new card and column components, improve pagination and filtering, and add lecturer information --- .../Assignment/AssignmentController.php | 16 +- .../Assignment/AssignmentService.php | 9 +- .../academic-classes/assignments/card.tsx | 225 ++++++++++ .../academic-classes/assignments/columns.tsx | 213 +++++++++ .../academic-classes/assignments/index.tsx | 408 ++++-------------- .../academic-classes/assignments/utils.ts | 22 + resources/js/types/assignment.ts | 7 + 7 files changed, 552 insertions(+), 348 deletions(-) create mode 100644 resources/js/pages/admin/academic-classes/assignments/card.tsx create mode 100644 resources/js/pages/admin/academic-classes/assignments/columns.tsx create mode 100644 resources/js/pages/admin/academic-classes/assignments/utils.ts diff --git a/app/Http/Controllers/Admin/AcademicClasses/Assignment/AssignmentController.php b/app/Http/Controllers/Admin/AcademicClasses/Assignment/AssignmentController.php index d852068..7ef236b 100644 --- a/app/Http/Controllers/Admin/AcademicClasses/Assignment/AssignmentController.php +++ b/app/Http/Controllers/Admin/AcademicClasses/Assignment/AssignmentController.php @@ -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']), ]); } diff --git a/app/Services/Admin/AcademicClasses/Assignment/AssignmentService.php b/app/Services/Admin/AcademicClasses/Assignment/AssignmentService.php index be2f12c..bbffe18 100644 --- a/app/Services/Admin/AcademicClasses/Assignment/AssignmentService.php +++ b/app/Services/Admin/AcademicClasses/Assignment/AssignmentService.php @@ -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 diff --git a/resources/js/pages/admin/academic-classes/assignments/card.tsx b/resources/js/pages/admin/academic-classes/assignments/card.tsx new file mode 100644 index 0000000..64c57dc --- /dev/null +++ b/resources/js/pages/admin/academic-classes/assignments/card.tsx @@ -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 ( +
+
+
+

+ {assignment.title} +

+ {assignment.course_class && ( + + {assignment.course_class.course?.code ?? ''}{' '} + {assignment.course_class.course?.name ?? ''} + + )} + {lecturerName && ( + + + {lecturerName} + + )} +
+ , + show: + canSubmit && assignment.status === 'open', + onClick: () => handleSubmitClick(assignment), + }, + { + label: 'Pengumpulan', + icon: ( + + ), + show: canViewSubmissions, + href: submissionsIndex.url(assignment.id), + }, + { + label: 'Edit', + icon: , + show: canUpdate, + onClick: () => handleEdit(assignment), + }, + { + label: 'Hapus', + icon: ( + + ), + show: canDelete, + onClick: () => handleDeleteClick(assignment), + }, + ]} + /> +
+ +
+ + {AssignmentStatusLabels[assignment.status]} + + {assignment.course_class?.academic_term && ( + + {formatAcademicTermLabel( + assignment.course_class.academic_term, + )} + + )} +
+ + + + {format(new Date(assignment.deadline), 'd MMM yyyy, HH:mm')} + + + {assignment.attachment_url && assignment.attachment_name ? ( +
+ +
+ ) : null} + +
+ {canSubmit ? ( + + {mySubmission?.status === 'submitted' + ? 'Sudah Mengumpulkan' + : 'Belum Mengumpulkan'} + + ) : ( + <> + + {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, + )} + %) + + + {assignment.graded_submissions_count.toLocaleString( + 'id-ID', + )}{' '} + /{' '} + {assignment.submissions_count.toLocaleString( + 'id-ID', + )}{' '} + Dinilai ( + {percentageOf( + assignment.graded_submissions_count, + assignment.submissions_count, + )} + %) + + + )} +
+ + {assignment.description && ( + + + + Deskripsi + + +

+ {assignment.description} +

+
+
+
+ )} +
+ ); + }; +} diff --git a/resources/js/pages/admin/academic-classes/assignments/columns.tsx b/resources/js/pages/admin/academic-classes/assignments/columns.tsx new file mode 100644 index 0000000..6d9f188 --- /dev/null +++ b/resources/js/pages/admin/academic-classes/assignments/columns.tsx @@ -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[] { + const { + handleEdit, + handleDeleteClick, + handleSubmitClick, + canUpdate, + canDelete, + canSubmit, + canViewSubmissions, + } = params; + + const columns: ColumnDef[] = [ + { + accessorKey: 'title', + header: () => Judul, + cell: ({ row }) => ( + + {row.getValue('title') as string} + + ), + }, + { + accessorKey: 'course_class.course.name', + header: () => Kelas, + cell: ({ row }) => { + const courseClass = row.original.course_class; + + if (!courseClass) { + return '-'; + } + + return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`; + }, + }, + { + accessorKey: 'deadline', + header: () => Batas Waktu, + cell: ({ row }) => { + const assignment = row.original; + + return ( + + {format( + new Date(assignment.deadline), + 'd MMM yyyy, HH:mm', + )} + + ); + }, + }, + { + accessorKey: 'status', + header: () => Status, + meta: { + className: 'text-center', + headerClassName: 'text-center', + }, + cell: ({ row }) => { + const assignment = row.original; + + return ( + + {AssignmentStatusLabels[assignment.status]} + + ); + }, + }, + { + id: 'submissions', + header: () => Pengumpulan, + cell: ({ row }) => { + const assignment = row.original; + + if (canSubmit) { + const mySubmission = assignment.submissions?.[0]; + + return ( + + {mySubmission?.status === 'submitted' + ? 'Sudah Mengumpulkan' + : 'Belum Mengumpulkan'} + + ); + } + + const enrollmentsCount = + assignment.course_class?.enrollments_count ?? 0; + + return ( +
+ + {assignment.submissions_count.toLocaleString( + 'id-ID', + )}{' '} + / {enrollmentsCount.toLocaleString('id-ID')}{' '} + mengumpulkan ( + {percentageOf( + assignment.submissions_count, + enrollmentsCount, + )} + %) + + + {assignment.graded_submissions_count.toLocaleString( + 'id-ID', + )}{' '} + /{' '} + {assignment.submissions_count.toLocaleString( + 'id-ID', + )}{' '} + dinilai ( + {percentageOf( + assignment.graded_submissions_count, + assignment.submissions_count, + )} + %) + +
+ ); + }, + }, + ]; + + columns.push({ + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[160px] text-center', + headerClassName: 'w-[160px] text-center', + }, + cell: ({ row }) => { + const assignment = row.original; + const mySubmission = assignment.submissions?.[0]; + + return ( +
+ , + show: + canSubmit && assignment.status === 'open', + onClick: () => handleSubmitClick(assignment), + }, + { + label: 'Pengumpulan', + icon: , + show: canViewSubmissions, + href: submissionsIndex.url(assignment.id), + }, + { + label: 'Edit', + icon: , + show: canUpdate, + onClick: () => handleEdit(assignment), + }, + { + label: 'Hapus', + icon: ( + + ), + show: canDelete, + onClick: () => handleDeleteClick(assignment), + }, + ]} + /> +
+ ); + }, + }); + + return columns; +} diff --git a/resources/js/pages/admin/academic-classes/assignments/index.tsx b/resources/js/pages/admin/academic-classes/assignments/index.tsx index 39ad044..5225c6f 100644 --- a/resources/js/pages/admin/academic-classes/assignments/index.tsx +++ b/resources/js/pages/admin/academic-classes/assignments/index.tsx @@ -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(null); const [deleting, setDeleting] = useState(null); const [submitting, setSubmitting] = useState(null); + const [view, setView] = useState('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) { - // 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 = ( + <> + + + + ); + return ( <> @@ -351,256 +318,31 @@ export default function AssignmentIndex({ assignment={submitting} /> -
- - handleSearchChange(event.target.value) - } - className="max-w-sm" + {view === 'table' ? ( + - -
- - {assignments.data.length === 0 ? ( -

- Belum ada tugas. -

) : ( - ( -

- Memuat tugas... -

- )} - > - {assignments.data.map((assignment) => { - const mySubmission = assignment.submissions?.[0]; - - return ( - - -
- - {assignment.title} - - {assignment.course_class && ( - - {assignment.course_class - .course?.code ?? - ''}{' '} - {assignment.course_class - .course?.name ?? ''} - - )} -
- - ), - show: - canSubmit && - assignment.status === - 'open', - onClick: () => - setSubmitting( - assignment, - ), - }, - { - label: 'Pengumpulan', - icon: ( - - ), - show: canViewSubmissions, - href: submissionsIndex.url( - assignment.id, - ), - }, - { - label: 'Edit', - icon: ( - - ), - show: canUpdate, - onClick: () => - setEditing(assignment), - }, - { - label: 'Hapus', - icon: ( - - ), - show: canDelete, - onClick: () => - setDeleting(assignment), - }, - ]} - /> -
- -
- - { - AssignmentStatusLabels[ - assignment.status - ] - } - - {assignment.course_class - ?.academic_term && ( - - {formatAcademicTermLabel( - assignment.course_class - .academic_term, - )} - - )} -
- - - {format( - new Date(assignment.deadline), - 'd MMM yyyy, HH:mm', - )} - - {assignment.attachment_url && - assignment.attachment_name ? ( -
- - -
- ) : null} -
- {canSubmit ? ( - - {mySubmission?.status === - 'submitted' - ? 'Sudah Mengumpulkan' - : 'Belum Mengumpulkan'} - - ) : ( - <> - - {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, - )} - %) - - - {assignment.graded_submissions_count.toLocaleString( - 'id-ID', - )}{' '} - /{' '} - {assignment.submissions_count.toLocaleString( - 'id-ID', - )}{' '} - Dinilai ( - {percentageOf( - assignment.graded_submissions_count, - assignment.submissions_count, - )} - %) - - - )} -
- - {assignment.description && ( - - - - Deskripsi - - -

- { - assignment.description - } -

-
-
-
- )} -
-
- ); - })} -
+ assignment.id} + searchKey="title" + pagination={pagination} + onPageChange={handlePageChange} + onPerPageChange={handlePerPageChange} + onSearchChange={handleSearchChange} + searchValue={search} + toolbar={toolbar} + /> )} 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'; +} diff --git a/resources/js/types/assignment.ts b/resources/js/types/assignment.ts index 03b2970..92c9959 100644 --- a/resources/js/types/assignment.ts +++ b/resources/js/types/assignment.ts @@ -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;