From a809307272c1c19d339ccd9bf673e3d246ac4894 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 2 Sep 2026 23:40:06 +0700 Subject: [PATCH] feat: implement assignment submission functionality with validation and user permissions --- .../AcademicClasses/SubmissionController.php | 10 +++ .../SubmitAssignmentRequest.php | 53 ++++++++++++ .../AcademicClasses/AssignmentService.php | 4 + .../AcademicClasses/SubmissionService.php | 24 ++++++ app/Support/PermissionCatalog.php | 1 + database/seeders/RolePermissionSeeder.php | 1 + .../academic-classes/assignments/columns.tsx | 83 ++++++++++++++----- .../academic-classes/assignments/index.tsx | 62 ++++++++++++++ resources/js/types/assignment.ts | 11 +++ routes/admin.php | 4 + 10 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 app/Http/Requests/Admin/AcademicClasses/SubmitAssignmentRequest.php diff --git a/app/Http/Controllers/Admin/AcademicClasses/SubmissionController.php b/app/Http/Controllers/Admin/AcademicClasses/SubmissionController.php index a86b5ba..6e1fc02 100644 --- a/app/Http/Controllers/Admin/AcademicClasses/SubmissionController.php +++ b/app/Http/Controllers/Admin/AcademicClasses/SubmissionController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Admin\AcademicClasses\SubmissionRequest; +use App\Http\Requests\Admin\AcademicClasses\SubmitAssignmentRequest; use App\Models\Assignment; use App\Models\Submission; use App\Services\Admin\AcademicClasses\SubmissionService; @@ -48,4 +49,13 @@ public function destroy(Assignment $assignment, Submission $submission): Redirec return Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengumpulan berhasil dihapus.'])->back(); } + + public function submit(SubmitAssignmentRequest $request, Assignment $assignment): RedirectResponse + { + $this->service->submitForStudent($assignment, $request->user()->student, $request->validated(), $request->file('file')); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil dikumpulkan.']); + + return to_route('admin.academic-classes.assignments.index'); + } } diff --git a/app/Http/Requests/Admin/AcademicClasses/SubmitAssignmentRequest.php b/app/Http/Requests/Admin/AcademicClasses/SubmitAssignmentRequest.php new file mode 100644 index 0000000..c5ec82d --- /dev/null +++ b/app/Http/Requests/Admin/AcademicClasses/SubmitAssignmentRequest.php @@ -0,0 +1,53 @@ +user()->can('submit-assignments')) { + return false; + } + + $student = $this->user()->student; + $assignment = $this->route('assignment'); + + if (! $student || ! $assignment) { + return false; + } + + return $assignment->courseClass() + ->whereHas('registrations', function ($q) use ($student) { + $q->where('student_id', $student->id) + ->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved)); + }) + ->exists(); + } + + public function rules(): array + { + $assignment = $this->route('assignment'); + $student = $this->user()->student; + + $hasExistingFile = Submission::query() + ->where('assignment_id', $assignment->id) + ->where('student_id', $student?->id) + ->whereHas('media') + ->exists(); + + return [ + 'notes' => ['nullable', 'string'], + 'file' => [ + $hasExistingFile ? 'nullable' : 'required', + 'file', + 'max:10240', + 'mimes:pdf,doc,docx,ppt,pptx,xls,xlsx,jpg,jpeg,png,mp4,zip', + ], + ]; + } +} diff --git a/app/Services/Admin/AcademicClasses/AssignmentService.php b/app/Services/Admin/AcademicClasses/AssignmentService.php index a15bd0c..c73663e 100644 --- a/app/Services/Admin/AcademicClasses/AssignmentService.php +++ b/app/Services/Admin/AcademicClasses/AssignmentService.php @@ -16,6 +16,10 @@ public function paginated(User $user, int $perPage = 25, string $search = '', ?i ->select(['id', 'course_class_id', 'title', 'description', 'deadline']) ->withCount('submissions') ->with('courseClass.course:id,code,name') + ->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($user->hasRole('dosen'), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))) diff --git a/app/Services/Admin/AcademicClasses/SubmissionService.php b/app/Services/Admin/AcademicClasses/SubmissionService.php index f58a4ce..3c55cc5 100644 --- a/app/Services/Admin/AcademicClasses/SubmissionService.php +++ b/app/Services/Admin/AcademicClasses/SubmissionService.php @@ -2,6 +2,7 @@ namespace App\Services\Admin\AcademicClasses; +use App\Enums\SubmissionStatus; use App\Models\Assignment; use App\Models\Student; use App\Models\Submission; @@ -10,6 +11,29 @@ class SubmissionService { + /** + * Self-service submission by the student themselves: creates their + * submission for this assignment, or updates it if they already + * submitted before (e.g. resubmitting). + */ + public function submitForStudent(Assignment $assignment, Student $student, array $data, ?UploadedFile $file): Submission + { + $submission = Submission::query()->updateOrCreate( + ['assignment_id' => $assignment->id, 'student_id' => $student->id], + [ + 'notes' => $data['notes'] ?? null, + 'status' => SubmissionStatus::Submitted, + 'submitted_at' => now(), + ], + ); + + if ($file) { + $submission->addMedia($file)->toMediaCollection('submission_file'); + } + + return $submission; + } + public function forAssignment(Assignment $assignment): Collection { return $assignment->submissions() diff --git a/app/Support/PermissionCatalog.php b/app/Support/PermissionCatalog.php index a490bec..ad4b745 100644 --- a/app/Support/PermissionCatalog.php +++ b/app/Support/PermissionCatalog.php @@ -16,6 +16,7 @@ class PermissionCatalog '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', + 'submit-assignments', 'view-schedules', 'create-schedules', 'update-schedules', 'delete-schedules', 'view-attendances', 'create-attendances', 'delete-attendances', ]; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index eacbf0c..67b6963 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -49,6 +49,7 @@ public function run(): void 'view-schedules', 'view-materials', 'view-assignments', + 'submit-assignments', ...$feedbackSelfService, ], 'dosen' => [ diff --git a/resources/js/pages/admin/academic-classes/assignments/columns.tsx b/resources/js/pages/admin/academic-classes/assignments/columns.tsx index 212ea87..239e654 100644 --- a/resources/js/pages/admin/academic-classes/assignments/columns.tsx +++ b/resources/js/pages/admin/academic-classes/assignments/columns.tsx @@ -1,8 +1,9 @@ import type { ColumnDef } from '@tanstack/react-table'; import { format } from 'date-fns'; -import { ClipboardList, Pencil, Trash2 } from 'lucide-react'; +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'; @@ -11,9 +12,11 @@ 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( @@ -22,9 +25,11 @@ export function createAssignmentColumns( const { handleEdit, handleDeleteClick, + handleSubmit, canUpdate, canDelete, canViewSubmissions, + canSubmit, } = params; const columns: ColumnDef[] = [ @@ -77,37 +82,77 @@ export function createAssignmentColumns( ); }, }, - { - accessorKey: 'submissions_count', - header: () => ( - Pengumpulan - ), - meta: { - className: 'w-[120px] text-center', - headerClassName: 'w-[120px] text-center', - }, - cell: ({ row }) => ( -
- {row.original.submissions_count} -
- ), - }, + 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) { + if (canViewSubmissions || canUpdate || canDelete || canSubmit) { columns.push({ id: 'actions', header: () => Aksi, meta: { - className: 'w-[130px] text-center', - headerClassName: 'w-[130px] text-center', + 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: , diff --git a/resources/js/pages/admin/academic-classes/assignments/index.tsx b/resources/js/pages/admin/academic-classes/assignments/index.tsx index 160790c..dbd27eb 100644 --- a/resources/js/pages/admin/academic-classes/assignments/index.tsx +++ b/resources/js/pages/admin/academic-classes/assignments/index.tsx @@ -32,6 +32,7 @@ import { index as assignmentIndex, destroy, store, + submit, update, } from '@/routes/admin/academic-classes/assignments'; import type { Assignment } from '@/types/assignment'; @@ -151,11 +152,13 @@ export default function AssignmentIndex({ const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); + const [submitting, setSubmitting] = useState(null); const { hasPermission } = usePermissions(); const canCreate = hasPermission('create-assignments'); const canUpdate = hasPermission('update-assignments'); const canDelete = hasPermission('delete-assignments'); const canViewSubmissions = hasPermission('view-assignment-submissions'); + const canSubmit = hasPermission('submit-assignments'); const filterFields = [ { @@ -198,9 +201,11 @@ export default function AssignmentIndex({ const columns = createAssignmentColumns({ handleEdit: (assignment) => setEditing(assignment), handleDeleteClick: (assignment) => setDeleting(assignment), + handleSubmit: (assignment) => setSubmitting(assignment), canUpdate, canDelete, canViewSubmissions, + canSubmit, }); return ( @@ -265,6 +270,17 @@ export default function AssignmentIndex({ courseClasses={courseClasses} /> + { + if (!open) { + setSubmitting(null); + } + }} + assignment={submitting} + /> + ); } + +function SubmitForm({ + open, + onOpenChange, + assignment, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + assignment: Assignment | null; +}) { + const mySubmission = assignment?.submissions?.[0]; + + return ( + onOpenChange(false)} + > + {({ errors }) => ( +
+
+ +