Merge pull request 'feat: add assignment and submission management features' (#22) from feat/add-assinment-and-submission into dev

Reviewed-on: #22
This commit is contained in:
pangestu 2026-08-24 19:16:25 +08:00
commit 424a2cd6e8
26 changed files with 1632 additions and 15 deletions

View File

@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum SubmissionStatus: string
{
case NotSubmitted = 'not_submitted';
case Submitted = 'submitted';
public function label(): string
{
return match ($this) {
self::NotSubmitted => 'Belum Mengumpulkan',
self::Submitted => 'Sudah Mengumpulkan',
};
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\AssignmentRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Assignment;
use App\Services\Admin\Manage\AssignmentService;
use App\Services\Admin\Manage\CourseClassService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class AssignmentController extends Controller
{
public function __construct(
private readonly AssignmentService $service,
private readonly CourseClassService $courseClassService,
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/manage/assignments/index', [
'assignments' => $this->service->paginated(...$request->validatedWithDefaults()),
'courseClasses' => $this->courseClassService->getAllForSelect(),
]);
}
public function store(AssignmentRequest $request): RedirectResponse
{
$this->service->create($request->validated(), $request->file('attachment'));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil ditambahkan.']);
return to_route('admin.manage.assignments.index');
}
public function update(AssignmentRequest $request, Assignment $assignment): RedirectResponse
{
$this->service->update($assignment, $request->validated(), $request->file('attachment'));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil diperbarui.']);
return to_route('admin.manage.assignments.index');
}
public function destroy(Assignment $assignment): RedirectResponse
{
$this->service->delete($assignment);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil dihapus.'])->back();
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\SubmissionRequest;
use App\Models\Assignment;
use App\Models\Submission;
use App\Services\Admin\Manage\SubmissionService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class SubmissionController extends Controller
{
public function __construct(
private readonly SubmissionService $service,
) {}
public function index(Assignment $assignment): Response
{
$assignment->load(['courseClass.course', 'courseClass.lecturer.user.profile', 'courseClass.academicTerm']);
return Inertia::render('admin/manage/assignments/submissions', [
'assignment' => $assignment,
'submissions' => $this->service->forAssignment($assignment),
'availableStudents' => $this->service->availableStudents($assignment),
]);
}
public function store(SubmissionRequest $request, Assignment $assignment): RedirectResponse
{
$this->service->create($assignment, $request->validated(), $request->file('file'));
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();
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AssignmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'course_class_id' => ['required', 'integer', Rule::exists('course_classes', 'id')],
'title' => ['required', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'deadline' => ['required', 'date'],
'attachment' => ['nullable', 'file', 'max:10240', 'mimes:pdf,doc,docx,ppt,pptx,xls,xlsx,jpg,jpeg,png,mp4,zip'],
];
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\SubmissionStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SubmissionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
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;
}
}

57
app/Models/Assignment.php Normal file
View File

@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
#[Guarded(['id'])]
#[Appends(['attachment_url', 'attachment_name'])]
class Assignment extends Model implements HasMedia
{
use HasFactory, InteractsWithMedia, SoftDeletes;
protected function casts(): array
{
return [
'deadline' => 'datetime',
];
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('assignment_attachment')->singleFile();
}
public function courseClass(): BelongsTo
{
return $this->belongsTo(CourseClass::class);
}
public function submissions(): HasMany
{
return $this->hasMany(Submission::class);
}
protected function attachmentUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('assignment_attachment') ?: null,
);
}
protected function attachmentName(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMedia('assignment_attachment')?->file_name,
);
}
}

View File

@ -46,4 +46,9 @@ public function materials(): HasMany
{
return $this->hasMany(Material::class);
}
public function assignments(): HasMany
{
return $this->hasMany(Assignment::class);
}
}

View File

@ -41,4 +41,9 @@ public function enrollments(): HasMany
{
return $this->hasMany(ClassEnrollment::class);
}
public function submissions(): HasMany
{
return $this->hasMany(Submission::class);
}
}

58
app/Models/Submission.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace App\Models;
use App\Enums\SubmissionStatus;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
#[Guarded(['id'])]
#[Appends(['file_url', 'file_name'])]
class Submission extends Model implements HasMedia
{
use HasFactory, InteractsWithMedia;
protected function casts(): array
{
return [
'status' => SubmissionStatus::class,
'submitted_at' => 'datetime',
'score' => 'decimal:2',
];
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('submission_file')->singleFile();
}
public function assignment(): BelongsTo
{
return $this->belongsTo(Assignment::class);
}
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
protected function fileUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('submission_file') ?: null,
);
}
protected function fileName(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMedia('submission_file')?->file_name,
);
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Services\Admin\Manage;
use App\Models\Assignment;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
class AssignmentService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Assignment::query()
->select(['id', 'course_class_id', 'title', 'description', 'deadline'])
->withCount('submissions')
->with('courseClass.course:id,code,name')
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
->orderBy($sort, $direction)
->paginate($perPage);
}
public function create(array $data, ?UploadedFile $file): Assignment
{
$assignment = Assignment::create([
'course_class_id' => $data['course_class_id'],
'title' => $data['title'],
'description' => $data['description'] ?? null,
'deadline' => $data['deadline'],
]);
if ($file) {
$assignment->addMedia($file)->toMediaCollection('assignment_attachment');
}
return $assignment;
}
public function update(Assignment $assignment, array $data, ?UploadedFile $file): Assignment
{
$assignment->course_class_id = $data['course_class_id'];
$assignment->title = $data['title'];
$assignment->description = $data['description'] ?? null;
$assignment->deadline = $data['deadline'];
$assignment->update();
if ($file) {
$assignment->addMedia($file)->toMediaCollection('assignment_attachment');
}
return $assignment;
}
public function delete(Assignment $assignment): bool
{
return $assignment->delete();
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Services\Admin\Manage;
use App\Models\Assignment;
use App\Models\Student;
use App\Models\Submission;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
class SubmissionService
{
public function forAssignment(Assignment $assignment): Collection
{
return $assignment->submissions()
->with(['student.user.profile', 'student.department'])
->latest('created_at')
->get();
}
public function availableStudents(Assignment $assignment): Collection
{
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->update();
if ($file) {
$submission->addMedia($file)->toMediaCollection('submission_file');
}
return $submission;
}
public function delete(Submission $submission): bool
{
return $submission->delete();
}
}

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('assignments', function (Blueprint $table) {
$table->id();
$table->foreignId('course_class_id')->constrained()->cascadeOnDelete();
$table->string('title', 150);
$table->text('description')->nullable();
$table->timestamp('deadline');
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('assignments');
}
};

View File

@ -0,0 +1,31 @@
<?php
use App\Enums\SubmissionStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('submissions', function (Blueprint $table) {
$table->id();
$table->foreignId('assignment_id')->constrained()->cascadeOnDelete();
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
$table->text('notes')->nullable();
$table->enum('status', array_values(SubmissionStatus::cases()))->nullable()->default(SubmissionStatus::NotSubmitted->value);
$table->timestamp('submitted_at')->nullable();
$table->decimal('score', 5, 2)->nullable();
$table->text('lecturer_feedback')->nullable();
$table->timestamps();
$table->unique(['assignment_id', 'student_id']);
});
}
public function down(): void
{
Schema::dropIfExists('submissions');
}
};

View File

@ -0,0 +1,31 @@
<?php
namespace Database\Seeders;
use App\Models\Assignment;
use Illuminate\Database\Seeder;
class AssignmentSeeder extends Seeder
{
public function run(): void
{
Assignment::insert([
[
'course_class_id' => 1,
'title' => 'Tugas Membuat Landing Page',
'description' => 'Buat landing page sederhana dengan HTML & CSS',
'deadline' => '2026-08-20 23:59:00',
'created_at' => '2026-08-05 09:00:00',
'updated_at' => '2026-08-05 09:00:00',
],
[
'course_class_id' => 2,
'title' => 'Studi Kasus Kualitas Produk',
'description' => 'Analisis studi kasus pengendalian kualitas produk',
'deadline' => '2026-08-25 23:59:00',
'created_at' => '2026-08-05 10:00:00',
'updated_at' => '2026-08-05 10:00:00',
],
]);
}
}

View File

@ -21,6 +21,8 @@ public function run(): void
CourseClassSeeder::class,
ClassEnrollmentSeeder::class,
MaterialSeeder::class,
AssignmentSeeder::class,
SubmissionSeeder::class,
]);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Database\Seeders;
use App\Models\Submission;
use Illuminate\Database\Seeder;
class SubmissionSeeder extends Seeder
{
public function run(): void
{
Submission::insert([
[
'assignment_id' => 1,
'student_id' => 1,
'notes' => 'Sudah selesai, mohon direview',
'status' => 'submitted',
'submitted_at' => '2026-08-18 20:00:00',
'score' => 85,
'lecturer_feedback' => 'Bagus, tampilan rapi',
'created_at' => '2026-08-18 20:00:00',
'updated_at' => '2026-08-19 07:00:00',
],
[
'assignment_id' => 2,
'student_id' => 2,
'notes' => null,
'status' => 'not_submitted',
'submitted_at' => null,
'score' => null,
'lecturer_feedback' => null,
'created_at' => '2026-08-05 10:05:00',
'updated_at' => '2026-08-05 10:05:00',
],
]);
}
}

View File

@ -6,10 +6,22 @@ import {
IconMessageDots,
IconUsers,
} from '@tabler/icons-react';
import {
BookOpen,
Building2,
Calendar,
ClipboardList,
FileText,
GraduationCap,
School,
User,
Users,
} from 'lucide-react';
import * as React from 'react';
import AppLogoIcon from '@/components/app-logo-icon';
import { NavMain, type NavGroup, type NavItem } from '@/components/nav-main';
import { NavMain } from '@/components/nav-main';
import type { NavGroup, NavItem } from '@/components/nav-main';
import { NavSecondary } from '@/components/nav-secondary';
import {
Sidebar,
@ -19,24 +31,15 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { index as assignmentsRoute } from '@/routes/admin/manage/assignments';
import { index as courseClassesRoute } from '@/routes/admin/manage/course-classes';
import { index as coursesRoute } from '@/routes/admin/manage/courses';
import { index as materialsRoute } from '@/routes/admin/manage/materials';
import { index as academicTerm } from '@/routes/admin/master/academic-terms';
import { index as departmentsRoute } from '@/routes/admin/master/departments';
import { index as administratorsRoute } from '@/routes/admin/users/administrators';
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
import { index as studentsRoute } from '@/routes/admin/users/students';
import { index as administratorsRoute } from '@/routes/admin/users/administrators';
import {
BookOpen,
Building2,
Calendar,
FileText,
GraduationCap,
School,
User,
Users,
} from 'lucide-react';
const data: {
navMain: (NavGroup | NavItem)[];
@ -86,6 +89,11 @@ const data: {
url: materialsRoute.url(),
icon: FileText,
},
{
name: 'Tugas',
url: assignmentsRoute.url(),
icon: ClipboardList,
},
],
},
{

View File

@ -48,7 +48,6 @@ export function DatePicker({
defaultMonth={value ?? undefined}
captionLayout="dropdown"
onSelect={onChange}
initialFocus
/>
</PopoverContent>
</Popover>

View File

@ -0,0 +1,67 @@
import { format } from 'date-fns';
import { useState } from 'react';
import { DatePicker } from '@/components/date-picker';
import InputError from '@/components/input-error';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type DateTimeFieldProps = {
label: string;
name: string;
required?: boolean;
defaultValue?: string | null;
error?: string;
placeholder?: string;
};
function parseDefault(value?: string | null): Date | undefined {
if (!value) {
return undefined;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
export function DateTimeField({
label,
name,
required,
defaultValue,
error,
placeholder = 'Pilih tanggal',
}: DateTimeFieldProps) {
const initial = parseDefault(defaultValue);
const [date, setDate] = useState<Date | undefined>(initial);
const [time, setTime] = useState(initial ? format(initial, 'HH:mm') : '');
const combined = date
? `${format(date, 'yyyy-MM-dd')} ${time || '00:00'}:00`
: '';
return (
<div className="grid gap-2">
<Label>
{label}{' '}
{required && <span className="text-destructive">*</span>}
</Label>
<input type="hidden" name={name} value={combined} />
<div className="flex gap-2">
<DatePicker
value={date}
onChange={setDate}
placeholder={placeholder}
className="flex-1"
/>
<Input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-28"
/>
</div>
<InputError message={error} />
</div>
);
}

View File

@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,

View File

@ -0,0 +1,130 @@
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { ClipboardList, Paperclip, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { index as submissionsIndex } from '@/routes/admin/manage/assignments/submissions';
import type { Assignment } from '@/types/assignment';
export type { Assignment } from '@/types/assignment';
type CreateColumnsParams = {
handleEdit: (assignment: Assignment) => void;
handleDeleteClick: (assignment: Assignment) => void;
};
export function createAssignmentColumns(
params: CreateColumnsParams,
): ColumnDef<Assignment>[] {
const { handleEdit, handleDeleteClick } = params;
return [
{
accessorKey: 'title',
header: () => <span>Judul</span>,
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('title') as string}
</span>
),
},
{
accessorKey: 'course_class.class_name',
header: () => <span>Kelas</span>,
cell: ({ row }) => {
const courseClass = row.original.course_class;
if (!courseClass) {
return '-';
}
const label = courseClass.class_name
? `${courseClass.class_name} - `
: '';
return `${label}${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`;
},
},
{
accessorKey: 'deadline',
header: () => <span>Batas Waktu</span>,
cell: ({ row }) => {
const deadline = row.getValue('deadline') as string;
return format(new Date(deadline), 'd MMM yyyy, HH:mm');
},
},
{
accessorKey: 'attachment_name',
header: () => <span>Lampiran</span>,
cell: ({ row }) => {
const assignment = row.original;
if (!assignment.attachment_url) {
return <span className="text-muted-foreground">-</span>;
}
return (
<a
href={assignment.attachment_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-primary underline underline-offset-4 hover:text-primary/80"
>
<Paperclip className="h-3.5 w-3.5" />
{assignment.attachment_name}
</a>
);
},
},
{
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>
),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[130px] text-center',
headerClassName: 'w-[130px] text-center',
},
cell: ({ row }) => {
const assignment = row.original;
return (
<RowActions
actions={[
{
label: 'Pengumpulan',
icon: <ClipboardList className="h-4 w-4" />,
href: submissionsIndex.url(assignment.id),
},
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(assignment),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(assignment),
},
]}
/>
);
},
},
];
}

View File

@ -0,0 +1,363 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
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 { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
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 { useServerTable } from '@/hooks/use-server-table';
import {
index as assignmentIndex,
destroy,
store,
update,
} from '@/routes/admin/manage/assignments';
import type { Assignment } from '@/types/assignment';
import { createAssignmentColumns } from './columns';
type CourseClassOption = {
id: number;
class_name: string | null;
course: { id: number; code: string; name: string } | null;
};
type Props = {
assignments: {
data: Assignment[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
courseClasses: CourseClassOption[];
highlight?: number;
};
function courseClassLabel(courseClass: CourseClassOption): string {
const namePart = courseClass.class_name
? `${courseClass.class_name} - `
: '';
return `${namePart}${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`;
}
export default function AssignmentIndex({
assignments,
courseClasses,
highlight,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Assignment | null>(null);
const [deleting, setDeleting] = useState<Assignment | null>(null);
const pagination: PaginationState = {
current_page: assignments.current_page,
last_page: assignments.last_page,
per_page: assignments.per_page,
total: assignments.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => assignmentIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
const columns = createAssignmentColumns({
handleEdit: (assignment) => setEditing(assignment),
handleDeleteClick: (assignment) => setDeleting(assignment),
});
return (
<>
<Head title="Tugas" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Tugas"
description={
highlight && (
<p className="mt-1 text-sm text-muted-foreground">
Menampilkan tugas dari notifikasi.
<button
onClick={() => {
router.get(
assignmentIndex.url(),
{},
{
replace: true,
preserveState: true,
},
);
}}
className="ml-1 text-primary underline underline-offset-4 hover:text-primary/80"
>
Tampilkan semua
</button>
</p>
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
}
/>
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
courseClasses={courseClasses}
/>
<EditForm
key={editing?.id}
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
editing={editing}
courseClasses={courseClasses}
/>
<DataTable
columns={columns}
data={assignments.data}
searchKey="title"
searchPlaceholder="Cari tugas..."
emptyText="Belum ada data tugas."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Tugas"
description={(assignment) =>
`Apakah Anda yakin ingin menghapus tugas "${assignment.title}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>
</>
);
}
function CreateForm({
open,
onOpenChange,
courseClasses,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
courseClasses: CourseClassOption[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tambah Tugas"
action={store()}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>
Kelas <span className="text-destructive">*</span>
</Label>
<input type="hidden" name="course_class_id" />
<Select name="course_class_id">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih kelas" />
</SelectTrigger>
<SelectContent>
{courseClasses.map((courseClass) => (
<SelectItem
key={courseClass.id}
value={String(courseClass.id)}
>
{courseClassLabel(courseClass)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.course_class_id} />
</div>
<div className="grid gap-2">
<Label htmlFor="title">
Judul <span className="text-destructive">*</span>
</Label>
<Input
id="title"
name="title"
placeholder="Masukkan judul tugas"
/>
<InputError message={errors.title} />
</div>
<div className="grid gap-2">
<Label htmlFor="description">Deskripsi</Label>
<Textarea
id="description"
name="description"
placeholder="Masukkan deskripsi tugas"
/>
<InputError message={errors.description} />
</div>
<DateTimeField
label="Batas Waktu"
name="deadline"
required
placeholder="Pilih tanggal batas waktu"
error={errors.deadline}
/>
<FileUploadField
key={open ? 'open' : 'closed'}
name="attachment"
label="Lampiran"
error={errors.attachment}
/>
</div>
)}
</FormDialog>
);
}
function EditForm({
open,
onOpenChange,
editing,
courseClasses,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Assignment | null;
courseClasses: CourseClassOption[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Edit Tugas"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) =>
editing && (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>
Kelas{' '}
<span className="text-destructive">*</span>
</Label>
<Select
name="course_class_id"
defaultValue={String(editing.course_class_id)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih kelas" />
</SelectTrigger>
<SelectContent>
{courseClasses.map((courseClass) => (
<SelectItem
key={courseClass.id}
value={String(courseClass.id)}
>
{courseClassLabel(courseClass)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.course_class_id} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-title">
Judul{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="edit-title"
name="title"
placeholder="Masukkan judul tugas"
defaultValue={editing.title}
/>
<InputError message={errors.title} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">Deskripsi</Label>
<Textarea
id="edit-description"
name="description"
placeholder="Masukkan deskripsi tugas"
defaultValue={editing.description ?? ''}
/>
<InputError message={errors.description} />
</div>
<DateTimeField
label="Batas Waktu"
name="deadline"
required
defaultValue={editing.deadline}
placeholder="Pilih tanggal batas waktu"
error={errors.deadline}
/>
<FileUploadField
name="attachment"
label="Lampiran"
existingFileName={editing.attachment_name}
existingFileUrl={editing.attachment_url}
error={errors.attachment}
/>
</div>
)
}
</FormDialog>
);
}

View File

@ -0,0 +1,428 @@
import { Head, router } from '@inertiajs/react';
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
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 { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { index as assignmentIndex } from '@/routes/admin/manage/assignments';
import {
destroy,
store,
update,
} from '@/routes/admin/manage/assignments/submissions';
import type { Assignment } from '@/types/assignment';
import type { Submission, SubmissionStudent } from '@/types/submission';
import { SubmissionStatusLabels, SubmissionStatuses } from '@/types/submission';
type Props = {
assignment: Assignment;
submissions: Submission[];
availableStudents: SubmissionStudent[];
};
export default function SubmissionIndex({
assignment,
submissions,
availableStudents,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Submission | null>(null);
const [deleting, setDeleting] = useState<Submission | null>(null);
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy([assignment.id, deleting.id]), {
onSuccess: () => setDeleting(null),
});
}
const columns: ColumnDef<Submission>[] = [
{
accessorKey: 'student.student_number',
header: () => <span>NIM</span>,
cell: ({ row }) => row.original.student?.student_number ?? '-',
},
{
accessorKey: 'student.user.profile.full_name',
header: () => <span>Nama Mahasiswa</span>,
cell: ({ row }) =>
row.original.student?.user?.profile?.full_name ?? '-',
},
{
accessorKey: 'status',
header: () => <span className="block text-center">Status</span>,
meta: {
className: 'w-[160px] text-center',
headerClassName: 'w-[160px] text-center',
},
cell: ({ row }) => {
const status = row.getValue('status') as
keyof typeof SubmissionStatusLabels | null;
return (
<div className="flex justify-center">
{status ? (
<Badge
variant={
status === 'submitted'
? 'default'
: 'secondary'
}
>
{SubmissionStatusLabels[status]}
</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
);
},
},
{
accessorKey: 'submitted_at',
header: () => <span>Waktu Kumpul</span>,
cell: ({ row }) => {
const submittedAt = row.getValue('submitted_at') as
string | null;
return submittedAt
? format(new Date(submittedAt), 'd MMM yyyy, HH:mm')
: '-';
},
},
{
accessorKey: 'score',
header: () => <span className="block text-center">Nilai</span>,
meta: {
className: 'w-[90px] text-center',
headerClassName: 'w-[90px] text-center',
},
cell: ({ row }) => (
<div className="text-center">{row.original.score ?? '-'}</div>
),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => setEditing(row.original),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => setDeleting(row.original),
},
]}
/>
),
},
];
return (
<>
<Head title="Pengumpulan Tugas" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Pengumpulan Tugas"
actions={
<Button variant="outline" asChild>
<a href={assignmentIndex.url()}>Kembali</a>
</Button>
}
/>
<Card>
<CardHeader>
<CardTitle>{assignment.title}</CardTitle>
</CardHeader>
<CardContent className="grid gap-1 text-sm text-muted-foreground">
<p>
Kelas:{' '}
{assignment.course_class?.class_name
? `${assignment.course_class.class_name} - `
: ''}
{assignment.course_class?.course?.code}{' '}
{assignment.course_class?.course?.name}
</p>
<p>
Batas Waktu:{' '}
{format(
new Date(assignment.deadline),
'd MMM yyyy, HH:mm',
)}
</p>
<p>Jumlah Pengumpulan: {submissions.length}</p>
</CardContent>
</Card>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
Daftar Pengumpulan
</h2>
<Button
onClick={() => setCreateOpen(true)}
disabled={availableStudents.length === 0}
>
<Plus className="h-4 w-4" />
Tambah Pengumpulan
</Button>
</div>
<DataTable
columns={columns}
data={submissions}
emptyText="Belum ada pengumpulan untuk tugas ini."
/>
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
assignmentId={assignment.id}
availableStudents={availableStudents}
/>
<EditForm
key={editing?.id}
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
assignmentId={assignment.id}
editing={editing}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
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}
/>
</div>
</>
);
}
function CreateForm({
open,
onOpenChange,
assignmentId,
availableStudents,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
assignmentId: number;
availableStudents: SubmissionStudent[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tambah Pengumpulan"
action={store(assignmentId)}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>
Mahasiswa{' '}
<span className="text-destructive">*</span>
</Label>
<input type="hidden" name="student_id" />
<Select name="student_id">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih mahasiswa" />
</SelectTrigger>
<SelectContent>
{availableStudents.map((student) => (
<SelectItem
key={student.id}
value={String(student.id)}
>
{student.user?.profile?.full_name ??
'N/A'}{' '}
- {student.student_number}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.student_id} />
</div>
<SubmissionFields
errors={errors}
resetKey={open ? 'open' : 'closed'}
/>
</div>
)}
</FormDialog>
);
}
function EditForm({
open,
onOpenChange,
assignmentId,
editing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
assignmentId: number;
editing: Submission | null;
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Edit Pengumpulan"
action={editing ? update([assignmentId, editing.id]) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) =>
editing && (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Mahasiswa</Label>
<p className="text-sm font-medium">
{editing.student?.user?.profile?.full_name ??
'N/A'}{' '}
- {editing.student?.student_number}
</p>
</div>
<SubmissionFields errors={errors} editing={editing} />
</div>
)
}
</FormDialog>
);
}
function SubmissionFields({
errors,
editing,
resetKey,
}: {
errors: Record<string, string>;
editing?: Submission;
resetKey?: string;
}) {
return (
<>
<div className="grid gap-2">
<Label htmlFor="notes">Catatan</Label>
<Textarea
id="notes"
name="notes"
placeholder="Catatan dari mahasiswa"
defaultValue={editing?.notes ?? ''}
/>
<InputError message={errors.notes} />
</div>
<div className="grid gap-2">
<Label>
Status <span className="text-destructive">*</span>
</Label>
<input type="hidden" name="status" />
<Select
name="status"
defaultValue={editing?.status ?? 'not_submitted'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status" />
</SelectTrigger>
<SelectContent>
{SubmissionStatuses.map((status) => (
<SelectItem key={status} value={status}>
{SubmissionStatusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.status} />
</div>
<DateTimeField
label="Waktu Kumpul"
name="submitted_at"
defaultValue={editing?.submitted_at}
placeholder="Pilih waktu kumpul"
error={errors.submitted_at}
/>
<div className="grid gap-2">
<Label htmlFor="score">Nilai</Label>
<Input
id="score"
name="score"
type="number"
min={0}
max={100}
step="0.01"
placeholder="0 - 100"
defaultValue={editing?.score ?? undefined}
/>
<InputError message={errors.score} />
</div>
<div className="grid gap-2">
<Label htmlFor="lecturer_feedback">Feedback Dosen</Label>
<Textarea
id="lecturer_feedback"
name="lecturer_feedback"
placeholder="Masukkan feedback untuk mahasiswa"
defaultValue={editing?.lecturer_feedback ?? ''}
/>
<InputError message={errors.lecturer_feedback} />
</div>
<FileUploadField
key={resetKey}
label="File Pengumpulan"
existingFileName={editing?.file_name}
existingFileUrl={editing?.file_url}
error={errors.file}
/>
</>
);
}

View File

@ -0,0 +1,17 @@
export type Assignment = {
id: number;
course_class_id: number;
course_class: {
id: number;
class_name: string | null;
course: { id: number; code: string; name: string } | null;
} | null;
title: string;
description: string | null;
deadline: string;
attachment_url: string | null;
attachment_name: string | null;
submissions_count: number;
created_at: string;
updated_at: string;
};

View File

@ -0,0 +1,31 @@
export const SubmissionStatuses = ['not_submitted', 'submitted'] as const;
export type SubmissionStatus = (typeof SubmissionStatuses)[number];
export const SubmissionStatusLabels: Record<SubmissionStatus, string> = {
not_submitted: 'Belum Mengumpulkan',
submitted: 'Sudah Mengumpulkan',
};
export type SubmissionStudent = {
id: number;
student_number: string;
department: { id: number; name: string } | null;
user: { profile: { full_name: string } | null } | null;
};
export type Submission = {
id: number;
assignment_id: number;
student_id: number;
student: SubmissionStudent | null;
notes: string | null;
status: SubmissionStatus | null;
submitted_at: string | null;
score: string | null;
lecturer_feedback: string | null;
file_url: string | null;
file_name: string | null;
created_at: string;
updated_at: string;
};

View File

@ -1,9 +1,11 @@
<?php
use App\Http\Controllers\Admin\Manage\AssignmentController;
use App\Http\Controllers\Admin\Manage\ClassEnrollmentController;
use App\Http\Controllers\Admin\Manage\CourseClassController;
use App\Http\Controllers\Admin\Manage\CourseController;
use App\Http\Controllers\Admin\Manage\MaterialController;
use App\Http\Controllers\Admin\Manage\SubmissionController;
use App\Http\Controllers\Admin\Master\AcademicTermController;
use App\Http\Controllers\Admin\Master\DepartmentController;
use App\Http\Controllers\Admin\Users\AdministratorController;
@ -28,6 +30,15 @@
});
Route::resource('materials', MaterialController::class)->except(['create', 'edit', 'show']);
Route::resource('assignments', AssignmentController::class)->except(['create', 'edit', 'show']);
Route::prefix('assignments/{assignment}/submissions')->name('assignments.submissions.')->group(function () {
Route::get('/', [SubmissionController::class, 'index'])->name('index');
Route::post('/', [SubmissionController::class, 'store'])->name('store');
Route::put('{submission}', [SubmissionController::class, 'update'])->name('update');
Route::delete('{submission}', [SubmissionController::class, 'destroy'])->name('destroy');
});
});
Route::prefix('admin/users')->name('admin.users.')->group(function () {