feat: implement assignment submission functionality with validation and user permissions

This commit is contained in:
Yoga Pangestu 2026-09-02 23:40:06 +07:00
parent be0897ad6e
commit a809307272
10 changed files with 234 additions and 19 deletions

View File

@ -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');
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\Admin\AcademicClasses;
use App\Enums\RegistrationStatus;
use App\Models\Submission;
use Illuminate\Foundation\Http\FormRequest;
class SubmitAssignmentRequest extends FormRequest
{
public function authorize(): bool
{
if (! $this->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',
],
];
}
}

View File

@ -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)))

View File

@ -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()

View File

@ -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',
];

View File

@ -49,6 +49,7 @@ public function run(): void
'view-schedules',
'view-materials',
'view-assignments',
'submit-assignments',
...$feedbackSelfService,
],
'dosen' => [

View File

@ -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<Assignment>[] = [
@ -77,37 +82,77 @@ export function createAssignmentColumns(
);
},
},
{
accessorKey: 'submissions_count',
header: () => (
<span className="block text-center">Pengumpulan</span>
),
meta: {
className: 'w-[120px] text-center',
headerClassName: 'w-[120px] text-center',
},
cell: ({ row }) => (
<div className="text-center">
{row.original.submissions_count}
</div>
),
},
canSubmit
? {
id: 'my_submission_status',
header: () => (
<span className="block text-center">Status Saya</span>
),
meta: {
className: 'w-[160px] text-center',
headerClassName: 'w-[160px] text-center',
},
cell: ({ row }) => {
const mySubmission = row.original.submissions?.[0];
return (
<div className="flex justify-center">
<Badge
variant={
mySubmission?.status === 'submitted'
? 'default'
: 'secondary'
}
>
{mySubmission?.status === 'submitted'
? 'Sudah Mengumpulkan'
: 'Belum Mengumpulkan'}
</Badge>
</div>
);
},
}
: {
accessorKey: 'submissions_count',
header: () => (
<span className="block text-center">Pengumpulan</span>
),
meta: {
className: 'w-[120px] text-center',
headerClassName: 'w-[120px] text-center',
},
cell: ({ row }) => (
<div className="text-center">
{row.original.submissions_count}
</div>
),
},
];
if (canViewSubmissions || canUpdate || canDelete) {
if (canViewSubmissions || canUpdate || canDelete || canSubmit) {
columns.push({
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
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 (
<RowActions
actions={[
{
label:
mySubmission?.status === 'submitted'
? 'Kumpulkan Ulang'
: 'Kumpulkan Tugas',
icon: <Upload className="h-4 w-4" />,
show: canSubmit,
onClick: () => handleSubmit(assignment),
},
{
label: 'Pengumpulan',
icon: <ClipboardList className="h-4 w-4" />,

View File

@ -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<Assignment | null>(null);
const [deleting, setDeleting] = useState<Assignment | null>(null);
const [submitting, setSubmitting] = useState<Assignment | null>(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}
/>
<SubmitForm
key={submitting?.id}
open={submitting !== null}
onOpenChange={(open) => {
if (!open) {
setSubmitting(null);
}
}}
assignment={submitting}
/>
<DataTable
columns={columns}
data={assignments.data}
@ -474,3 +490,49 @@ function EditForm({
</FormDialog>
);
}
function SubmitForm({
open,
onOpenChange,
assignment,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
assignment: Assignment | null;
}) {
const mySubmission = assignment?.submissions?.[0];
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Kumpulkan Tugas"
action={assignment ? submit(assignment.id) : ''}
resetOnSuccess
submitLabel="Kumpulkan"
submittingLabel="Mengumpulkan..."
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="notes">Catatan</Label>
<Textarea
id="notes"
name="notes"
placeholder="Catatan untuk dosen (opsional)"
defaultValue={mySubmission?.notes ?? ''}
/>
<InputError message={errors.notes} />
</div>
<FileUploadField
label="File Tugas"
existingFileName={mySubmission?.file_name}
existingFileUrl={mySubmission?.file_url}
error={errors.file}
/>
</div>
)}
</FormDialog>
);
}

View File

@ -1,3 +1,12 @@
export type MySubmission = {
id: number;
notes: string | null;
status: 'not_submitted' | 'submitted' | null;
submitted_at: string | null;
file_url: string | null;
file_name: string | null;
};
export type Assignment = {
id: number;
course_class_id: number;
@ -11,6 +20,8 @@ export type Assignment = {
attachment_url: string | null;
attachment_name: string | null;
submissions_count: number;
/** Only present for the logged-in mahasiswa: their own submission, if any. */
submissions?: MySubmission[];
created_at: string;
updated_at: string;
};

View File

@ -73,6 +73,10 @@
Route::delete('{submission}', [SubmissionController::class, 'destroy'])->name('destroy')->middleware('permission:delete-assignment-submissions');
});
Route::post('assignments/{assignment}/submit', [SubmissionController::class, 'submit'])
->name('assignments.submit')
->middleware('permission:submit-assignments');
Route::resource('schedules', ScheduleController::class)
->except(['create', 'edit', 'show'])
->middlewareFor(['index'], 'permission:view-schedules')