diff --git a/app/Enums/AssignmentStatus.php b/app/Enums/AssignmentStatus.php new file mode 100644 index 0000000..2dd5367 --- /dev/null +++ b/app/Enums/AssignmentStatus.php @@ -0,0 +1,21 @@ + 'Dibuka', + self::Closed => 'Ditutup', + }; + } +} diff --git a/app/Http/Controllers/Admin/AcademicClasses/AssignmentController.php b/app/Http/Controllers/Admin/AcademicClasses/AssignmentController.php index 646845f..2c46da5 100644 --- a/app/Http/Controllers/Admin/AcademicClasses/AssignmentController.php +++ b/app/Http/Controllers/Admin/AcademicClasses/AssignmentController.php @@ -8,6 +8,7 @@ use App\Models\Assignment; use App\Services\Admin\AcademicClasses\AssignmentService; use App\Services\Admin\Manage\CourseClassService; +use App\Services\Admin\Master\AcademicTermService; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -17,18 +18,28 @@ class AssignmentController extends Controller public function __construct( private readonly AssignmentService $service, private readonly CourseClassService $courseClassService, + private readonly AcademicTermService $academicTermService, ) {} 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' => $this->service->paginated( + 'assignments' => Inertia::scroll(fn () => $this->service->paginated( $request->user(), ...$request->validatedWithDefaults(), courseClassId: $request->validated('course_class_id'), - ), + academicTermId: $academicTermId, + )), 'courseClasses' => $this->courseClassService->getAllForSelect($request->user()), - 'filters' => $request->only(['course_class_id']), + 'academicTerms' => $this->academicTermService->getAllForSelect(), + 'filters' => [ + 'course_class_id' => $request->validated('course_class_id'), + 'academic_term_id' => $academicTermId ? (string) $academicTermId : null, + ], ]); } diff --git a/app/Http/Controllers/Admin/AcademicClasses/SubmissionController.php b/app/Http/Controllers/Admin/AcademicClasses/SubmissionController.php index 6e1fc02..6570f31 100644 --- a/app/Http/Controllers/Admin/AcademicClasses/SubmissionController.php +++ b/app/Http/Controllers/Admin/AcademicClasses/SubmissionController.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers\Admin\AcademicClasses; use App\Http\Controllers\Controller; -use App\Http\Requests\Admin\AcademicClasses\SubmissionRequest; +use App\Http\Requests\Admin\AcademicClasses\GradeSubmissionRequest; use App\Http\Requests\Admin\AcademicClasses\SubmitAssignmentRequest; use App\Models\Assignment; use App\Models\Submission; @@ -20,34 +20,21 @@ public function __construct( public function index(Assignment $assignment): Response { + $this->abortUnlessLecturerOwnsAssignment($assignment); + $assignment->load(['courseClass.course', 'courseClass.lecturer.user.profile', 'courseClass.academicTerm']); return Inertia::render('admin/academic-classes/assignments/submissions', [ 'assignment' => $assignment, 'submissions' => $this->service->forAssignment($assignment), - 'availableStudents' => $this->service->availableStudents($assignment), ]); } - public function store(SubmissionRequest $request, Assignment $assignment): RedirectResponse + public function grade(GradeSubmissionRequest $request, Assignment $assignment, Submission $submission): RedirectResponse { - $this->service->create($assignment, $request->validated(), $request->file('file')); + $this->service->grade($submission, $request->validated('score')); - return Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengumpulan berhasil ditambahkan.'])->back(); - } - - public function update(SubmissionRequest $request, Assignment $assignment, Submission $submission): RedirectResponse - { - $this->service->update($submission, $request->validated(), $request->file('file')); - - return Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengumpulan berhasil diperbarui.'])->back(); - } - - public function destroy(Assignment $assignment, Submission $submission): RedirectResponse - { - $this->service->delete($submission); - - return Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengumpulan berhasil dihapus.'])->back(); + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Nilai berhasil disimpan.'])->back(); } public function submit(SubmitAssignmentRequest $request, Assignment $assignment): RedirectResponse @@ -58,4 +45,17 @@ public function submit(SubmitAssignmentRequest $request, Assignment $assignment) return to_route('admin.academic-classes.assignments.index'); } + + /** + * A dosen may only manage submissions for classes they lecture. + */ + private function abortUnlessLecturerOwnsAssignment(Assignment $assignment): void + { + $user = request()->user(); + + abort_if( + $user->hasRole('dosen') && $assignment->courseClass?->lecturer_id !== $user->lecturer?->id, + 403, + ); + } } diff --git a/app/Http/Requests/Admin/AcademicClasses/AssignmentRequest.php b/app/Http/Requests/Admin/AcademicClasses/AssignmentRequest.php index 7517482..f300437 100644 --- a/app/Http/Requests/Admin/AcademicClasses/AssignmentRequest.php +++ b/app/Http/Requests/Admin/AcademicClasses/AssignmentRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Admin\AcademicClasses; +use App\Enums\AssignmentStatus; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -27,6 +28,7 @@ public function rules(): array 'title' => ['required', 'string', 'max:150'], 'description' => ['nullable', 'string'], 'deadline' => ['required', 'date'], + 'status' => ['nullable', Rule::enum(AssignmentStatus::class)], 'attachment' => ['nullable', 'file', 'max:10240', 'mimes:pdf,doc,docx,ppt,pptx,xls,xlsx,jpg,jpeg,png,mp4,zip'], ]; } diff --git a/app/Http/Requests/Admin/AcademicClasses/GradeSubmissionRequest.php b/app/Http/Requests/Admin/AcademicClasses/GradeSubmissionRequest.php new file mode 100644 index 0000000..3e89077 --- /dev/null +++ b/app/Http/Requests/Admin/AcademicClasses/GradeSubmissionRequest.php @@ -0,0 +1,31 @@ +user()->can('update-assignment-submissions')) { + return false; + } + + $user = $this->user(); + $assignment = $this->route('assignment'); + + if ($user->hasRole('dosen') && $assignment?->courseClass?->lecturer_id !== $user->lecturer?->id) { + return false; + } + + return true; + } + + public function rules(): array + { + return [ + 'score' => ['nullable', 'numeric', 'min:0', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AcademicClasses/SubmissionRequest.php b/app/Http/Requests/Admin/AcademicClasses/SubmissionRequest.php deleted file mode 100644 index b625385..0000000 --- a/app/Http/Requests/Admin/AcademicClasses/SubmissionRequest.php +++ /dev/null @@ -1,41 +0,0 @@ -user()->can($this->isMethod('post') ? 'create-assignment-submissions' : 'update-assignment-submissions'); - } - - public function rules(): array - { - $assignment = $this->route('assignment'); - $submission = $this->route('submission'); - - $rules = [ - 'notes' => ['nullable', 'string'], - 'status' => ['required', Rule::enum(SubmissionStatus::class)], - 'submitted_at' => ['nullable', 'date'], - 'score' => ['nullable', 'numeric', 'min:0', 'max:100'], - 'lecturer_feedback' => ['nullable', 'string'], - 'file' => ['nullable', 'file', 'max:10240', 'mimes:pdf,doc,docx,ppt,pptx,xls,xlsx,jpg,jpeg,png,mp4,zip'], - ]; - - if (! $submission) { - $rules['student_id'] = [ - 'required', - 'integer', - Rule::exists('class_enrollments', 'student_id')->where('course_class_id', $assignment->course_class_id), - Rule::unique('submissions', 'student_id')->where('assignment_id', $assignment->id), - ]; - } - - return $rules; - } -} diff --git a/app/Http/Requests/Admin/AcademicClasses/SubmitAssignmentRequest.php b/app/Http/Requests/Admin/AcademicClasses/SubmitAssignmentRequest.php index c5ec82d..fcb7dfe 100644 --- a/app/Http/Requests/Admin/AcademicClasses/SubmitAssignmentRequest.php +++ b/app/Http/Requests/Admin/AcademicClasses/SubmitAssignmentRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Admin\AcademicClasses; +use App\Enums\AssignmentStatus; use App\Enums\RegistrationStatus; use App\Models\Submission; use Illuminate\Foundation\Http\FormRequest; @@ -21,6 +22,12 @@ public function authorize(): bool return false; } + // Whether the assignment still accepts submissions is governed by its + // status, not the deadline — a lecturer closes it explicitly. + if ($assignment->status !== AssignmentStatus::Open) { + return false; + } + return $assignment->courseClass() ->whereHas('registrations', function ($q) use ($student) { $q->where('student_id', $student->id) diff --git a/app/Models/Assignment.php b/app/Models/Assignment.php index 0aa2466..edb8eb5 100644 --- a/app/Models/Assignment.php +++ b/app/Models/Assignment.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\AssignmentStatus; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -23,6 +24,7 @@ protected function casts(): array { return [ 'deadline' => 'datetime', + 'status' => AssignmentStatus::class, ]; } diff --git a/app/Services/Admin/AcademicClasses/AssignmentService.php b/app/Services/Admin/AcademicClasses/AssignmentService.php index c73663e..161d24d 100644 --- a/app/Services/Admin/AcademicClasses/AssignmentService.php +++ b/app/Services/Admin/AcademicClasses/AssignmentService.php @@ -2,6 +2,7 @@ namespace App\Services\Admin\AcademicClasses; +use App\Enums\AssignmentStatus; use App\Enums\RegistrationStatus; use App\Models\Assignment; use App\Models\User; @@ -10,18 +11,23 @@ class AssignmentService { - public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null): LengthAwarePaginator + 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']) - ->withCount('submissions') - ->with('courseClass.course:id,code,name') + ->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'])]) ->when($user->hasRole('mahasiswa'), 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); }])) ->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%")) ->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId)) + ->when($academicTermId, fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('academic_term_id', $academicTermId))) ->when($user->hasRole('dosen'), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))) ->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) { $q->whereHas('registrations', function ($q) use ($user) { @@ -40,6 +46,7 @@ public function create(array $data, ?UploadedFile $file): Assignment 'title' => $data['title'], 'description' => $data['description'] ?? null, 'deadline' => $data['deadline'], + 'status' => $data['status'] ?? AssignmentStatus::Open, ]); if ($file) { @@ -55,6 +62,7 @@ public function update(Assignment $assignment, array $data, ?UploadedFile $file) $assignment->title = $data['title']; $assignment->description = $data['description'] ?? null; $assignment->deadline = $data['deadline']; + $assignment->status = $data['status'] ?? $assignment->status; $assignment->update(); if ($file) { diff --git a/app/Services/Admin/AcademicClasses/SubmissionService.php b/app/Services/Admin/AcademicClasses/SubmissionService.php index 3c55cc5..389ae5d 100644 --- a/app/Services/Admin/AcademicClasses/SubmissionService.php +++ b/app/Services/Admin/AcademicClasses/SubmissionService.php @@ -42,51 +42,11 @@ public function forAssignment(Assignment $assignment): Collection ->get(); } - public function availableStudents(Assignment $assignment): Collection + public function grade(Submission $submission, ?float $score): Submission { - return Student::query() - ->whereHas('enrollments', fn ($q) => $q->where('course_class_id', $assignment->course_class_id)) - ->whereDoesntHave('submissions', fn ($q) => $q->where('assignment_id', $assignment->id)) - ->with(['user.profile', 'department']) - ->get(); - } - - public function create(Assignment $assignment, array $data, ?UploadedFile $file): Submission - { - $submission = $assignment->submissions()->create([ - 'student_id' => $data['student_id'], - 'notes' => $data['notes'] ?? null, - 'status' => $data['status'], - 'submitted_at' => $data['submitted_at'] ?? null, - 'score' => $data['score'] ?? null, - 'lecturer_feedback' => $data['lecturer_feedback'] ?? null, - ]); - - if ($file) { - $submission->addMedia($file)->toMediaCollection('submission_file'); - } - - return $submission; - } - - public function update(Submission $submission, array $data, ?UploadedFile $file): Submission - { - $submission->notes = $data['notes'] ?? null; - $submission->status = $data['status']; - $submission->submitted_at = $data['submitted_at'] ?? null; - $submission->score = $data['score'] ?? null; - $submission->lecturer_feedback = $data['lecturer_feedback'] ?? null; + $submission->score = $score; $submission->update(); - if ($file) { - $submission->addMedia($file)->toMediaCollection('submission_file'); - } - return $submission; } - - public function delete(Submission $submission): bool - { - return $submission->delete(); - } } diff --git a/app/Support/PermissionCatalog.php b/app/Support/PermissionCatalog.php index ad4b745..aa0ddf5 100644 --- a/app/Support/PermissionCatalog.php +++ b/app/Support/PermissionCatalog.php @@ -15,7 +15,7 @@ class PermissionCatalog public const ACADEMIC_CLASSES = [ 'view-materials', 'create-materials', 'update-materials', 'delete-materials', 'view-assignments', 'create-assignments', 'update-assignments', 'delete-assignments', - 'view-assignment-submissions', 'create-assignment-submissions', 'update-assignment-submissions', 'delete-assignment-submissions', + 'view-assignment-submissions', 'update-assignment-submissions', 'submit-assignments', 'view-schedules', 'create-schedules', 'update-schedules', 'delete-schedules', 'view-attendances', 'create-attendances', 'delete-attendances', diff --git a/database/migrations/2026_09_03_000001_add_status_to_assignments_table.php b/database/migrations/2026_09_03_000001_add_status_to_assignments_table.php new file mode 100644 index 0000000..8fbc7b0 --- /dev/null +++ b/database/migrations/2026_09_03_000001_add_status_to_assignments_table.php @@ -0,0 +1,25 @@ +enum('status', AssignmentStatus::values()) + ->default(AssignmentStatus::Open->value) + ->after('deadline'); + }); + } + + public function down(): void + { + Schema::table('assignments', function (Blueprint $table) { + $table->dropColumn('status'); + }); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 67b6963..c3f7ca2 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -69,6 +69,8 @@ public function run(): void 'create-assignments', 'update-assignments', 'delete-assignments', + 'view-assignment-submissions', + 'update-assignment-submissions', ...$feedbackSelfService, ], 'staff-admin' => [ diff --git a/resources/js/components/ui/accordion.tsx b/resources/js/components/ui/accordion.tsx new file mode 100644 index 0000000..59d8fd6 --- /dev/null +++ b/resources/js/components/ui/accordion.tsx @@ -0,0 +1,63 @@ +import { ChevronDownIcon } from "lucide-react" +import { Accordion as AccordionPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } diff --git a/resources/js/hooks/use-server-table.tsx b/resources/js/hooks/use-server-table.tsx index 8df4574..e7d1026 100644 --- a/resources/js/hooks/use-server-table.tsx +++ b/resources/js/hooks/use-server-table.tsx @@ -7,6 +7,12 @@ type UseServerTableOptions = { pagination: PaginationState; filters?: Record; filterWithParams?: boolean; + /** + * Prop keys to pass as Inertia's `reset` visit option whenever search or + * filters change, so mergeable props (e.g. `Inertia::scroll()` used for + * infinite scroll) are replaced instead of appended to. + */ + resetKeys?: string[]; }; export function useServerTable({ @@ -14,6 +20,7 @@ export function useServerTable({ pagination, filters, filterWithParams = true, + resetKeys, }: UseServerTableOptions) { const [search, setSearch] = useState( () => new URLSearchParams(window.location.search).get('search') ?? '', @@ -63,10 +70,14 @@ export function useServerTable({ per_page: pagination.per_page, search: value, }, - { preserveState: true, replace: true }, + { + preserveState: true, + replace: true, + ...(resetKeys ? { reset: resetKeys } : {}), + }, ); }, - [route, filters, pagination.per_page], + [route, filters, pagination.per_page, resetKeys], ); function applyFilter(key: string, value: string) { @@ -88,7 +99,11 @@ export function useServerTable({ search, } : newFilters, - { preserveState: true, replace: true }, + { + preserveState: true, + replace: true, + ...(resetKeys ? { reset: resetKeys } : {}), + }, ); } @@ -103,7 +118,11 @@ export function useServerTable({ search, } : newFilters, - { preserveState: true, replace: true }, + { + preserveState: true, + replace: true, + ...(resetKeys ? { reset: resetKeys } : {}), + }, ); } @@ -117,7 +136,11 @@ export function useServerTable({ search, } : {}, - { preserveState: true, replace: true }, + { + preserveState: true, + replace: true, + ...(resetKeys ? { reset: resetKeys } : {}), + }, ); setFilterOpen(false); } diff --git a/resources/js/pages/admin/academic-classes/assignments/columns.tsx b/resources/js/pages/admin/academic-classes/assignments/columns.tsx deleted file mode 100644 index 239e654..0000000 --- a/resources/js/pages/admin/academic-classes/assignments/columns.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import type { ColumnDef } from '@tanstack/react-table'; -import { format } from 'date-fns'; -import { ClipboardList, Pencil, Trash2, Upload } from 'lucide-react'; -import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog'; -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'; - -export type { Assignment } from '@/types/assignment'; - -type CreateColumnsParams = { - handleEdit: (assignment: Assignment) => void; - handleDeleteClick: (assignment: Assignment) => void; - handleSubmit: (assignment: Assignment) => void; - canUpdate: boolean; - canDelete: boolean; - canViewSubmissions: boolean; - canSubmit: boolean; -}; - -export function createAssignmentColumns( - params: CreateColumnsParams, -): ColumnDef[] { - const { - handleEdit, - handleDeleteClick, - handleSubmit, - canUpdate, - canDelete, - canViewSubmissions, - canSubmit, - } = 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 deadline = row.getValue('deadline') as string; - - return format(new Date(deadline), 'd MMM yyyy, HH:mm'); - }, - }, - { - accessorKey: 'attachment_name', - header: () => Lampiran, - cell: ({ row }) => { - const assignment = row.original; - - if (!assignment.attachment_url || !assignment.attachment_name) { - return -; - } - - return ( - - ); - }, - }, - canSubmit - ? { - id: 'my_submission_status', - header: () => ( - Status Saya - ), - meta: { - className: 'w-[160px] text-center', - headerClassName: 'w-[160px] text-center', - }, - cell: ({ row }) => { - const mySubmission = row.original.submissions?.[0]; - - return ( -
- - {mySubmission?.status === 'submitted' - ? 'Sudah Mengumpulkan' - : 'Belum Mengumpulkan'} - -
- ); - }, - } - : { - accessorKey: 'submissions_count', - header: () => ( - Pengumpulan - ), - meta: { - className: 'w-[120px] text-center', - headerClassName: 'w-[120px] text-center', - }, - cell: ({ row }) => ( -
- {row.original.submissions_count} -
- ), - }, - ]; - - if (canViewSubmissions || canUpdate || canDelete || canSubmit) { - columns.push({ - id: 'actions', - header: () => Aksi, - meta: { - className: 'w-[150px] text-center', - headerClassName: 'w-[150px] text-center', - }, - cell: ({ row }) => { - const assignment = row.original; - const mySubmission = assignment.submissions?.[0]; - - return ( - , - show: canSubmit, - onClick: () => handleSubmit(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 dbd27eb..39ad044 100644 --- a/resources/js/pages/admin/academic-classes/assignments/index.tsx +++ b/resources/js/pages/admin/academic-classes/assignments/index.tsx @@ -1,17 +1,37 @@ -import { Head, router } from '@inertiajs/react'; -import { Plus } from 'lucide-react'; +import { Head, InfiniteScroll, router } from '@inertiajs/react'; +import { format } from 'date-fns'; +import { + Clock, + ClipboardList, + Paperclip, + Pencil, + Plus, + Trash2, + Upload, +} from 'lucide-react'; import { useState } from 'react'; -import type { PaginationState } from '@/components/data-table'; -import { DataTable } from '@/components/data-table'; +import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog'; import { DateTimeField } from '@/components/datetime-field'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { FileUploadField } from '@/components/file-upload-field'; -import type { FilterOptionGroup } from '@/components/filter-dialog'; +import type { + FilterField, + 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, @@ -25,6 +45,13 @@ import { } from '@/components/ui/combobox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { usePermissions } from '@/hooks/use-permissions'; import { useServerTable } from '@/hooks/use-server-table'; @@ -35,8 +62,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 { createAssignmentColumns } from './columns'; +import { AssignmentStatusLabels, AssignmentStatuses } from '@/types/assignment'; type CourseClassOption = { id: number; @@ -51,6 +80,12 @@ type CourseClassOption = { type CourseClassGroup = { value: string; items: CourseClassOption[] }; +type AcademicTermOption = { + id: number; + academic_year: string; + semester: string; +}; + type Props = { assignments: { data: Assignment[]; @@ -60,9 +95,11 @@ type Props = { total: number; }; courseClasses: CourseClassOption[]; + academicTerms: AcademicTermOption[]; highlight?: number; filters: { course_class_id?: string; + academic_term_id?: string; }; }; @@ -70,6 +107,29 @@ 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[] { @@ -130,7 +190,10 @@ function CourseClassField({ {group.value} {(option: CourseClassOption) => ( - + {courseClassLabel(option)} )} @@ -146,6 +209,7 @@ function CourseClassField({ export default function AssignmentIndex({ assignments, courseClasses, + academicTerms, highlight, filters, }: Props) { @@ -160,7 +224,15 @@ export default function AssignmentIndex({ const canViewSubmissions = hasPermission('view-assignment-submissions'); const canSubmit = hasPermission('submit-assignments'); - const filterFields = [ + const filterFields: FilterField[] = [ + { + key: 'academic_term_id', + label: 'Periode Akademik', + options: academicTerms.map((term) => ({ + value: String(term.id), + label: formatAcademicTermLabel(term), + })), + }, { key: 'course_class_id', label: 'Kelas', @@ -169,25 +241,33 @@ export default function AssignmentIndex({ }, ]; - const pagination: PaginationState = { + const pagination = { current_page: assignments.current_page, last_page: assignments.last_page, per_page: assignments.per_page, total: assignments.total, }; - const { - search, - handlePageChange, - handlePerPageChange, - handleSearchChange, - applyFilters, - } = useServerTable({ + const { search, 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; @@ -198,16 +278,6 @@ export default function AssignmentIndex({ }); } - const columns = createAssignmentColumns({ - handleEdit: (assignment) => setEditing(assignment), - handleDeleteClick: (assignment) => setDeleting(assignment), - handleSubmit: (assignment) => setSubmitting(assignment), - canUpdate, - canDelete, - canViewSubmissions, - canSubmit, - }); - return ( <> @@ -281,23 +351,257 @@ export default function AssignmentIndex({ assignment={submitting} /> - - } - /> +
+ + handleSearchChange(event.target.value) + } + className="max-w-sm" + /> + +
+ + {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 + } +

+
+
+
+ )} +
+
+ ); + })} +
+ )} +
+ + + +

+ Menutup tugas akan mencegah mahasiswa + mengumpulkan, terlepas dari batas waktu. +

+ +
(null); - const [deleting, setDeleting] = useState(null); - const { hasPermission } = usePermissions(); - const canCreate = hasPermission('create-assignment-submissions'); - const canUpdate = hasPermission('update-assignment-submissions'); - const canDelete = hasPermission('delete-assignment-submissions'); +function ScoreCell({ + assignmentId, + submission, + canUpdate, +}: { + assignmentId: number; + submission: Submission; + canUpdate: boolean; +}) { + const normalizedScore = submission.score ?? ''; + const [value, setValue] = useState(normalizedScore); + const [savedScore, setSavedScore] = useState(normalizedScore); + const [saving, setSaving] = useState(false); - function handleDelete() { - if (!deleting) { - return; - } - - router.delete(destroy([assignment.id, deleting.id]), { - onSuccess: () => setDeleting(null), - }); + // Sync local state when the score changes externally (e.g. after save). + if (normalizedScore !== savedScore) { + setSavedScore(normalizedScore); + setValue(normalizedScore); } + if (!canUpdate) { + return
{submission.score ?? '-'}
; + } + + const dirty = value !== normalizedScore; + + function handleSave() { + setSaving(true); + router.patch( + grade.url([assignmentId, submission.id]), + { score: value === '' ? null : value }, + { + preserveScroll: true, + preserveState: true, + onFinish: () => setSaving(false), + }, + ); + } + + return ( +
+ setValue(event.target.value)} + className="h-8 w-20 text-center" + /> + {dirty && ( + + )} +
+ ); +} + +export default function SubmissionIndex({ assignment, submissions }: Props) { + const { hasPermission } = usePermissions(); + const canUpdate = hasPermission('update-assignment-submissions'); + const columns: ColumnDef[] = [ { accessorKey: 'student.student_number', @@ -122,46 +150,19 @@ export default function SubmissionIndex({ accessorKey: 'score', header: () => Nilai, meta: { - className: 'w-[90px] text-center', - headerClassName: 'w-[90px] text-center', + className: 'w-[140px] text-center', + headerClassName: 'w-[140px] text-center', }, cell: ({ row }) => ( -
{row.original.score ?? '-'}
+ ), }, ]; - if (canUpdate || canDelete) { - columns.push({ - id: 'actions', - header: () => Aksi, - meta: { - className: 'w-[100px] text-center', - headerClassName: 'w-[100px] text-center', - }, - cell: ({ row }) => ( - , - show: canUpdate, - onClick: () => setEditing(row.original), - }, - { - label: 'Hapus', - icon: ( - - ), - show: canDelete, - onClick: () => setDeleting(row.original), - }, - ]} - /> - ), - }); - } - return ( <> @@ -180,8 +181,17 @@ export default function SubmissionIndex({ /> - + {assignment.title} + + {AssignmentStatusLabels[assignment.status]} +

@@ -199,237 +209,10 @@ export default function SubmissionIndex({ -

-

- Daftar Pengumpulan -

- {canCreate && ( - - )} -
+

Daftar Pengumpulan

- - - - { - if (!open) { - setEditing(null); - } - }} - assignmentId={assignment.id} - editing={editing} - /> - - { - if (!open) { - setDeleting(null); - } - }} - title="Hapus Pengumpulan" - description={(submission) => - `Apakah Anda yakin ingin menghapus pengumpulan "${submission.student?.user?.profile?.full_name ?? 'mahasiswa ini'}"? Tindakan ini tidak dapat dibatalkan.` - } - onConfirm={handleDelete} - /> ); } - -function CreateForm({ - open, - onOpenChange, - assignmentId, - availableStudents, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - assignmentId: number; - availableStudents: SubmissionStudent[]; -}) { - return ( - onOpenChange(false)} - > - {({ errors }) => ( -
-
- - - - -
- -
- )} -
- ); -} - -function EditForm({ - open, - onOpenChange, - assignmentId, - editing, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - assignmentId: number; - editing: Submission | null; -}) { - return ( - onOpenChange(false)} - > - {({ errors }) => - editing && ( -
-
- -

- {editing.student?.user?.profile?.full_name ?? - 'N/A'}{' '} - - {editing.student?.student_number} -

-
- -
- ) - } -
- ); -} - -function SubmissionFields({ - errors, - editing, - resetKey, -}: { - errors: Record; - editing?: Submission; - resetKey?: string; -}) { - return ( - <> -
- -