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;