From aad22a8b5988e66aae1d1973fa2bf8d64bbf2770 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 11 Mar 2026 13:34:54 +0700 Subject: [PATCH] feat: implement comprehensive assignment management system with models, migrations, Filament resources, and submission workflows. --- app/Enums/AssignmentType.php | 19 ++ .../Actions/SelectAllStudentsAction.php | 26 ++ .../Assignments/Actions/SubmitAction.php | 39 +++ .../Assignments/AssignmentResource.php | 49 ++++ .../Assignments/Pages/CreateAssignment.php | 94 +++++++ .../Assignments/Pages/EditAssignment.php | 118 ++++++++ .../Assignments/Pages/ListAssignments.php | 259 ++++++++++++++++++ .../Pages/SubmissionDetailPage.php | 174 ++++++++++++ .../Pages/SubmitAssignmentPage.php | 231 ++++++++++++++++ .../Assignments/Schemas/AssignmentForm.php | 128 +++++++++ app/Models/Assignment.php | 74 +++++ app/Models/AssignmentPin.php | 24 ++ app/Models/AssignmentSubmission.php | 44 +++ app/Models/AssignmentTarget.php | 33 +++ ..._02_25_024510_create_assignments_table.php | 35 +++ ...024511_create_assignment_targets_table.php | 33 +++ ...12_create_assignment_submissions_table.php | 34 +++ ...03_083151_create_assignment_pins_table.php | 27 ++ .../pages/list-assignments.blade.php | 151 ++++++++++ .../pages/submission-detail.blade.php | 125 +++++++++ .../pages/submit-assignment.blade.php | 191 +++++++++++++ 21 files changed, 1908 insertions(+) create mode 100644 app/Enums/AssignmentType.php create mode 100644 app/Filament/Resources/Learning/Assignments/Actions/SelectAllStudentsAction.php create mode 100644 app/Filament/Resources/Learning/Assignments/Actions/SubmitAction.php create mode 100644 app/Filament/Resources/Learning/Assignments/AssignmentResource.php create mode 100644 app/Filament/Resources/Learning/Assignments/Pages/CreateAssignment.php create mode 100644 app/Filament/Resources/Learning/Assignments/Pages/EditAssignment.php create mode 100644 app/Filament/Resources/Learning/Assignments/Pages/ListAssignments.php create mode 100644 app/Filament/Resources/Learning/Assignments/Pages/SubmissionDetailPage.php create mode 100644 app/Filament/Resources/Learning/Assignments/Pages/SubmitAssignmentPage.php create mode 100644 app/Filament/Resources/Learning/Assignments/Schemas/AssignmentForm.php create mode 100644 app/Models/Assignment.php create mode 100644 app/Models/AssignmentPin.php create mode 100644 app/Models/AssignmentSubmission.php create mode 100644 app/Models/AssignmentTarget.php create mode 100644 database/migrations/2026_02_25_024510_create_assignments_table.php create mode 100644 database/migrations/2026_02_25_024511_create_assignment_targets_table.php create mode 100644 database/migrations/2026_02_25_024512_create_assignment_submissions_table.php create mode 100644 database/migrations/2026_03_03_083151_create_assignment_pins_table.php create mode 100644 resources/views/filament/resources/learning/assignments/pages/list-assignments.blade.php create mode 100644 resources/views/filament/resources/learning/assignments/pages/submission-detail.blade.php create mode 100644 resources/views/filament/resources/learning/assignments/pages/submit-assignment.blade.php diff --git a/app/Enums/AssignmentType.php b/app/Enums/AssignmentType.php new file mode 100644 index 0000000..a38b50b --- /dev/null +++ b/app/Enums/AssignmentType.php @@ -0,0 +1,19 @@ + 'Individu', + self::Group => 'Kelompok', + }; + } +} diff --git a/app/Filament/Resources/Learning/Assignments/Actions/SelectAllStudentsAction.php b/app/Filament/Resources/Learning/Assignments/Actions/SelectAllStudentsAction.php new file mode 100644 index 0000000..51339d5 --- /dev/null +++ b/app/Filament/Resources/Learning/Assignments/Actions/SelectAllStudentsAction.php @@ -0,0 +1,26 @@ +label('Pilih Semua') + ->action(function (Set $set) { + $studentIds = Student::pluck('id')->toArray(); + $set('student_ids', $studentIds); + }); + } +} diff --git a/app/Filament/Resources/Learning/Assignments/Actions/SubmitAction.php b/app/Filament/Resources/Learning/Assignments/Actions/SubmitAction.php new file mode 100644 index 0000000..888db25 --- /dev/null +++ b/app/Filament/Resources/Learning/Assignments/Actions/SubmitAction.php @@ -0,0 +1,39 @@ +label('Kumpulkan') + ->icon('heroicon-o-arrow-up-tray') + ->color('primary') + ->url(fn (Assignment $record) => AssignmentResource::getUrl('submit', ['record' => $record->id])) + ->visible(function (Assignment $record) { + if (now()->isAfter($record->due_date)) { + return false; + } + + if ($record->type === AssignmentType::Group) { + return $record->studyGroups() + ->where('leader_id', auth()->user()->student->id) + ->exists(); + } + + return true; + }); + } +} diff --git a/app/Filament/Resources/Learning/Assignments/AssignmentResource.php b/app/Filament/Resources/Learning/Assignments/AssignmentResource.php new file mode 100644 index 0000000..f2433b1 --- /dev/null +++ b/app/Filament/Resources/Learning/Assignments/AssignmentResource.php @@ -0,0 +1,49 @@ + ListAssignments::route('/'), + 'create' => CreateAssignment::route('/create'), + 'edit' => EditAssignment::route('/{record}/edit'), + 'submission-detail' => SubmissionDetailPage::route('/{record}/submission-detail'), + 'submit' => SubmitAssignmentPage::route('/{record}/submit'), + ]; + } +} diff --git a/app/Filament/Resources/Learning/Assignments/Pages/CreateAssignment.php b/app/Filament/Resources/Learning/Assignments/Pages/CreateAssignment.php new file mode 100644 index 0000000..d0d3b20 --- /dev/null +++ b/app/Filament/Resources/Learning/Assignments/Pages/CreateAssignment.php @@ -0,0 +1,94 @@ +url(ListAssignments::getUrl()), + ]; + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index'); + } + + protected function getCreatedNotification(): ?Notification + { + return SystemNotification::create(); + } + + protected function mutateFormDataBeforeCreate(array $data): array + { + return $data; + } + + public function handleRecordCreation(array $data): Model + { + return DB::transaction(function () use ($data) { + $studentIds = $data['student_ids'] ?? []; + $studyGroupIds = $data['study_group_ids'] ?? []; + $pdf = $data['pdf'] ?? null; + + unset($data['student_ids'], $data['study_group_ids'], $data['pdf']); + + $assignment = $this->getResource()::getModel()::create($data); + + if ($assignment->type === AssignmentType::Individual) { + foreach ($studentIds as $studentId) { + AssignmentTarget::create([ + 'assignment_id' => $assignment->id, + 'student_id' => $studentId, + ]); + } + } else { + $studyGroupIds = StudyGroup::whereHas('courses', function ($q) use ($assignment) { + $q->where('courses.id', $assignment->course_id); + })->pluck('id'); + + foreach ($studyGroupIds as $groupId) { + AssignmentTarget::create([ + 'assignment_id' => $assignment->id, + 'study_group_id' => $groupId, + ]); + } + } + + if (! empty($pdf)) { + $filePath = is_array($pdf) ? reset($pdf) : $pdf; + + $assignment->addMediaFromDisk($filePath, config('filesystems.default')) + ->preservingOriginal() + ->withCustomProperties([ + 'feature' => 'assignments', + 'date' => now()->toDateString(), + 'doc_type' => 'tasks', + ]) + ->toMediaCollection('assignments'); + } + + return $assignment; + }); + } +} diff --git a/app/Filament/Resources/Learning/Assignments/Pages/EditAssignment.php b/app/Filament/Resources/Learning/Assignments/Pages/EditAssignment.php new file mode 100644 index 0000000..3c329ea --- /dev/null +++ b/app/Filament/Resources/Learning/Assignments/Pages/EditAssignment.php @@ -0,0 +1,118 @@ +url(ListAssignments::getUrl()), + ]; + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index'); + } + + protected function getSavedNotification(): ?Notification + { + return SystemNotification::update(); + } + + protected function mutateFormDataBeforeFill(array $data): array + { + $data['student_ids'] = $this->record->assignmentTargets() + ->whereNotNull('student_id') + ->pluck('student_id') + ->toArray(); + + $data['study_group_ids'] = $this->record->assignmentTargets() + ->whereNotNull('study_group_id') + ->pluck('study_group_id') + ->toArray(); + + $media = $this->record->getMedia('assignments')->last(); + $relativePath = $media ? $media->getPathRelativeToRoot() : null; + + $data['pdf'] = $relativePath ? [$relativePath] : []; + + return $data; + } + + public function handleRecordUpdate(Model $record, array $data): Model + { + return DB::transaction(function () use ($record, $data) { + $currentMedia = $record->getMedia('assignments')->last(); + $currentPath = $currentMedia ? $currentMedia->getPathRelativeToRoot() : null; + + $studentIds = $data['student_ids'] ?? []; + $studyGroupIds = $data['study_group_ids'] ?? []; + $pdf = $data['pdf'] ?? null; + + unset($data['student_ids'], $data['study_group_ids'], $data['pdf']); + + $record->update($data); + + $record->assignmentTargets()->delete(); + + if ($record->type === AssignmentType::Individual) { + foreach ($studentIds as $studentId) { + AssignmentTarget::create([ + 'assignment_id' => $record->id, + 'student_id' => $studentId, + ]); + } + } else { + $studyGroupIds = StudyGroup::whereHas('courses', function ($q) use ($record) { + $q->where('courses.id', $record->course_id); + })->pluck('id'); + + foreach ($studyGroupIds as $groupId) { + AssignmentTarget::create([ + 'assignment_id' => $record->id, + 'study_group_id' => $groupId, + ]); + } + } + + $filePath = is_array($pdf) ? reset($pdf) : $pdf; + + if ($filePath !== $currentPath) { + $record->clearMediaCollection('assignments'); + + if (! empty($filePath)) { + $record->addMediaFromDisk($filePath, config('filesystems.default')) + ->preservingOriginal() + ->withCustomProperties([ + 'feature' => 'assignments', + 'date' => now()->toDateString(), + 'doc_type' => 'tasks', + ]) + ->toMediaCollection('assignments'); + } + } + + return $record; + }); + } +} diff --git a/app/Filament/Resources/Learning/Assignments/Pages/ListAssignments.php b/app/Filament/Resources/Learning/Assignments/Pages/ListAssignments.php new file mode 100644 index 0000000..1a0d7b2 --- /dev/null +++ b/app/Filament/Resources/Learning/Assignments/Pages/ListAssignments.php @@ -0,0 +1,259 @@ +user()->student; + + $pinnedIds = $this->pinnedIds; + + return Assignment::with(['course', 'assignmentSubmissions' => function ($q) use ($studentProfile) { + $q->where('student_id', $studentProfile->id) + ->orWhereHas('studyGroup', fn ($sq) => $sq->whereHas('students', fn ($ssq) => $ssq->whereKey($studentProfile->id))); + }, 'studyGroups.students']) + ->where(function ($query) use ($studentProfile) { + $query->whereHas('students', fn ($q) => $q->whereKey($studentProfile->id)) + ->orWhereHas('studyGroups', fn ($q) => $q->whereHas('students', fn ($sq) => $sq->whereKey($studentProfile->id))); + }) + ->when(! empty($pinnedIds), function ($query) use ($pinnedIds) { + $ids = implode(',', $pinnedIds); + $query->orderByRaw("FIELD(id, $ids) DESC"); + }) + ->orderBy('due_date') + ->get(); + } + + #[Computed] + public function assignmentCards() + { + $studentProfile = auth()->user()->student; + $pinnedIds = $this->pinnedIds; + + return $this->assignments()->map(function ($assignment) use ($studentProfile, $pinnedIds) { + $submission = $assignment->assignmentSubmissions->first(); + $isSubmitted = $submission !== null; + $isGroup = $assignment->type === \App\Enums\AssignmentType::Group; + + $isLeader = false; + if ($isGroup) { + $userGroup = $assignment->studyGroups->first(function ($g) use ($studentProfile) { + return $g->students->contains($studentProfile->id); + }); + $isLeader = $userGroup && $userGroup->leader_id === $studentProfile->id; + } + + $isOverdue = now()->isAfter($assignment->due_date); + $canSubmit = ! $isOverdue; + + $canSubmitByRole = ! $isGroup || $isLeader; + $canSubmitActual = $canSubmit && $canSubmitByRole; + + $isUrgent = ! $isSubmitted && $canSubmitActual && now()->diffInHours($assignment->due_date) <= 48; + $isNew = $assignment->created_at->diffInDays(now()) <= 3; + $isPinned = in_array($assignment->id, $pinnedIds); + + $statusLabel = 'Belum Dikumpulkan'; + $statusColor = 'warning'; + $statusIcon = 'heroicon-o-arrow-up-tray'; + + if ($isSubmitted) { + $statusLabel = 'โœ“ Sudah Dikumpulkan'; + $statusColor = 'success'; + $statusIcon = 'heroicon-o-check-circle'; + } elseif ($isOverdue) { + $statusLabel = 'โฐ Waktu Habis'; + $statusColor = 'danger'; + $statusIcon = 'heroicon-o-clock'; + } elseif ($isGroup && ! $isLeader && ! $isSubmitted) { + $statusLabel = 'Menunggu Ketua'; + $statusColor = 'gray'; + $statusIcon = 'heroicon-o-user-group'; + } + + return (object) [ + 'id' => $assignment->id, + 'title' => $assignment->title, + 'course_name' => $assignment->course?->name ?? '-', + 'due_date_formatted' => $assignment->due_date->translatedFormat('l, d F Y, H:i'), + 'submitted_at_formatted' => ($isSubmitted && $submission->submitted_at) ? $submission->submitted_at->translatedFormat('l, d F Y, H:i') : null, + 'is_submitted' => $isSubmitted, + 'is_group' => $isGroup, + 'is_leader' => $isLeader, + 'is_overdue' => $isOverdue, + 'can_submit_actual' => $canSubmitActual, + 'is_urgent' => $isUrgent, + 'is_new' => $isNew, + 'is_pinned' => $isPinned, + 'status_label' => $statusLabel, + 'status_color' => $statusColor, + 'status_icon' => $statusIcon, + ]; + }); + } + + #[Computed] + public function pinnedIds(): array + { + $studentProfile = auth()->user()->student; + + return AssignmentPin::where('student_id', $studentProfile->id) + ->pluck('assignment_id') + ->toArray(); + } + + public function togglePin(int $assignmentId): void + { + $studentProfile = auth()->user()->student; + + $existing = AssignmentPin::where('student_id', $studentProfile->id) + ->where('assignment_id', $assignmentId) + ->first(); + + if ($existing) { + $existing->delete(); + SystemNotification::success('Pin Tugas Dilepas ๐Ÿ“Œ', 'Pin pada tugas ini telah berhasil dilepas dari daftar prioritas Anda.')->send(); + } else { + AssignmentPin::create([ + 'student_id' => $studentProfile->id, + 'assignment_id' => $assignmentId, + ]); + SystemNotification::success('Tugas Berhasil Di-pin ๐Ÿ“', 'Tugas ini sekarang berada di posisi teratas daftar prioritas Anda.')->send(); + } + + unset($this->assignments, $this->pinnedIds); + } + + public function editAssignmentAction(): Action + { + return EditAction::make('editAssignment') + ->record(fn (array $arguments) => Assignment::find($arguments['record'])) + ->modalHeading(fn (Assignment $record) => "Ubah {$record->title}") + ->modalWidth(Width::FourExtraLarge) + ->schema(fn (Schema $schema) => AssignmentForm::configure($schema)->getComponents()) + ->fillForm(function (Assignment $record): array { + $data = $record->toArray(); + + $data['student_ids'] = $record->assignmentTargets() + ->whereNotNull('student_id') + ->pluck('student_id') + ->toArray(); + + $data['study_group_ids'] = $record->assignmentTargets() + ->whereNotNull('study_group_id') + ->pluck('study_group_id') + ->toArray(); + + $media = $record->getMedia('assignments')->last(); + $relativePath = $media ? $media->getPathRelativeToRoot() : null; + $data['pdf'] = $relativePath ? [$relativePath] : []; + + return $data; + }) + ->using(function (Assignment $record, array $data): Assignment { + return DB::transaction(function () use ($record, $data) { + $currentMedia = $record->getMedia('assignments')->last(); + $currentPath = $currentMedia ? $currentMedia->getPathRelativeToRoot() : null; + + $studentIds = $data['student_ids'] ?? []; + $pdf = $data['pdf'] ?? null; + + unset($data['student_ids'], $data['study_group_ids'], $data['pdf']); + + $record->update($data); + + $record->assignmentTargets()->delete(); + + if ($record->type === AssignmentType::Individual) { + foreach ($studentIds as $studentId) { + AssignmentTarget::create([ + 'assignment_id' => $record->id, + 'student_id' => $studentId, + ]); + } + } else { + $studyGroupIds = StudyGroup::whereHas('courses', function ($q) use ($record) { + $q->where('courses.id', $record->course_id); + })->pluck('id'); + + foreach ($studyGroupIds as $groupId) { + AssignmentTarget::create([ + 'assignment_id' => $record->id, + 'study_group_id' => $groupId, + ]); + } + } + + $filePath = is_array($pdf) ? reset($pdf) : $pdf; + + if ($filePath !== $currentPath) { + $record->clearMediaCollection('assignments'); + if (! empty($filePath)) { + $record->addMediaFromDisk($filePath, config('filesystems.default')) + ->preservingOriginal() + ->withCustomProperties([ + 'feature' => 'assignments', + 'date' => now()->toDateString(), + 'doc_type' => 'tasks', + ]) + ->toMediaCollection('assignments'); + } + } + + return $record; + }); + }) + ->after(function () { + unset($this->assignments); + }); + } + + public function deleteAssignmentAction(): Action + { + return DeleteAction::make('deleteAssignment') + ->record(fn (array $arguments) => Assignment::find($arguments['record'])) + ->after(function () { + unset($this->assignments); + }); + } + + protected function getHeaderActions(): array + { + return [ + CreateAction::make() + ->label('Tambah'), + ]; + } +} diff --git a/app/Filament/Resources/Learning/Assignments/Pages/SubmissionDetailPage.php b/app/Filament/Resources/Learning/Assignments/Pages/SubmissionDetailPage.php new file mode 100644 index 0000000..146cdc5 --- /dev/null +++ b/app/Filament/Resources/Learning/Assignments/Pages/SubmissionDetailPage.php @@ -0,0 +1,174 @@ + $this->record->type === AssignmentType::Individual ? 'Total Mahasiswa' : 'Total Kelompok', + 'value' => $this->totalCount, + 'icon' => 'heroicon-o-user-group', + 'color' => 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-900', + ], + [ + 'label' => 'Sudah Kumpul', + 'value' => $this->doneCount, + 'icon' => 'heroicon-o-check-circle', + 'color' => 'bg-success-100 dark:bg-success-900/30 text-success-700 dark:text-success-400', + ], + [ + 'label' => 'Belum Kumpul', + 'value' => $this->totalCount - $this->doneCount, + 'icon' => 'heroicon-o-x-circle', + 'color' => 'bg-danger-100 dark:bg-danger-900/30 text-danger-700 dark:text-danger-400', + ], + [ + 'label' => 'Persentase', + 'value' => $this->percentage.'%', + 'icon' => 'heroicon-o-chart-bar', + 'color' => 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400', + ], + ]; + } + + #[Computed] + public function submissionSummary(): Collection + { + $assignment = $this->record->load(['students', 'studyGroups', 'course']); + $isIndividual = $assignment->type === AssignmentType::Individual; + + if ($isIndividual) { + $targets = $assignment->students->keyBy('id'); + + $submissions = AssignmentSubmission::with('student') + ->where('assignment_id', $assignment->id) + ->get() + ->keyBy('student_id'); + + return $targets->map(function ($student) use ($submissions) { + $submission = $submissions->get($student->id); + $isSubmitted = $submission !== null; + + return (object) [ + 'id' => $student->id, + 'is_individual' => true, + 'primary_name' => $student->full_name, + 'secondary_info' => $student->student_number, + 'submission' => $submission, + 'submitted' => $isSubmitted, + 'submitted_at_formatted' => $isSubmitted ? $submission->submitted_at?->translatedFormat('l, d F Y, H:i') : '-', + 'has_file' => $isSubmitted && $submission->hasMedia('submission'), + ]; + })->sortBy('secondary_info')->values(); + } else { + $targets = $assignment->studyGroups->keyBy('id'); + + $submissions = AssignmentSubmission::with(['studyGroup', 'student']) + ->where('assignment_id', $assignment->id) + ->get() + ->keyBy('study_group_id'); + + return $targets->map(function ($group) use ($submissions) { + $submission = $submissions->get($group->id); + $isSubmitted = $submission !== null; + + return (object) [ + 'id' => $group->id, + 'is_individual' => false, + 'primary_name' => $group->name, + 'secondary_info' => $isSubmitted ? $submission->student->full_name : '-', + 'submission' => $submission, + 'submitted' => $isSubmitted, + 'submitted_at_formatted' => $isSubmitted ? $submission->submitted_at?->translatedFormat('l, d F Y, H:i') : '-', + 'has_file' => $isSubmitted && $submission->hasMedia('submission'), + ]; + })->sortBy('primary_name')->values(); + } + } + + #[Computed] + public function totalCount(): int + { + return $this->submissionSummary->count(); + } + + #[Computed] + public function doneCount(): int + { + return $this->submissionSummary->where('submitted', true)->count(); + } + + #[Computed] + public function percentage(): int + { + $total = $this->totalCount; + + return $total > 0 ? (int) round(($this->doneCount / $total) * 100) : 0; + } + + #[Computed] + public function isOverdue(): bool + { + return now()->isAfter($this->record->due_date); + } + + public function getAssignmentInfo(): array + { + return [ + 'title' => $this->record->title, + 'course' => $this->record->course?->name ?? '-', + 'due_date' => $this->record->due_date?->translatedFormat('l, d F Y, H:i'), + 'type' => $this->record->type->value, + 'is_overdue' => $this->isOverdue, + ]; + } + + public function previewSubmissionAction(): Action + { + return Action::make('previewSubmission') + ->record(fn (array $arguments) => AssignmentSubmission::find($arguments['submissionId'])) + ->modalHeading(fn (AssignmentSubmission $record) => 'File Tugas: '.($record->study_group_id ? $record->studyGroup->name : $record->student->full_name)) + ->modalWidth(Width::SixExtraLarge) + ->infolist([ + PdfViewerEntry::make('submission') + ->hiddenLabel() + ->fileUrl(fn (AssignmentSubmission $record) => $record->getFirstMediaUrl('submission')) + ->columnSpanFull(), + ]) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Tutup'); + } + + protected function getHeaderActions(): array + { + return [ + + BackAction::make() + ->url(AssignmentResource::getUrl()), + ]; + } +} diff --git a/app/Filament/Resources/Learning/Assignments/Pages/SubmitAssignmentPage.php b/app/Filament/Resources/Learning/Assignments/Pages/SubmitAssignmentPage.php new file mode 100644 index 0000000..2864b39 --- /dev/null +++ b/app/Filament/Resources/Learning/Assignments/Pages/SubmitAssignmentPage.php @@ -0,0 +1,231 @@ + 'Batas Waktu', + 'value' => $this->record->due_date?->translatedFormat('l, d F Y, H:i'), + 'icon' => 'heroicon-o-clock', + 'is_danger' => $this->isOverdue, + 'badge' => $this->isOverdue ? '(Terlewat)' : null, + ], + [ + 'label' => 'Status Pengumpulan', + 'value' => $this->isOverdue ? 'Ditutup' : 'Terbuka', + 'icon' => $this->isOverdue ? 'heroicon-o-lock-closed' : 'heroicon-o-check-circle', + 'is_danger' => $this->isOverdue, + 'is_success' => ! $this->isOverdue, + ], + ]; + } + + #[Computed] + public function student(): Student + { + return auth()->user()->student; + } + + #[Computed] + public function currentGroup(): ?StudyGroup + { + $student = $this->student; + + return $this->record->studyGroups() + ->whereHas('students', fn ($q) => $q->whereKey($student->id)) + ->first(); + } + + #[Computed] + public function existingSubmission(): ?AssignmentSubmission + { + $student = $this->student; + + if ($this->record->type === AssignmentType::Group) { + $group = $this->currentGroup; + if (! $group) { + return null; + } + + return AssignmentSubmission::where('assignment_id', $this->record->id) + ->where('study_group_id', $group->id) + ->first(); + } + + return AssignmentSubmission::where('assignment_id', $this->record->id) + ->where('student_id', $student->id) + ->first(); + } + + #[Computed] + public function isResubmit(): bool + { + return $this->existingSubmission !== null; + } + + #[Computed] + public function isOverdue(): bool + { + return now()->isAfter($this->record->due_date); + } + + #[Computed] + public function canSubmit(): bool + { + if ($this->record->type === AssignmentType::Individual) { + return true; + } + + $group = $this->currentGroup; + + return $group && $group->leader_id === $this->student->id; + } + + #[Computed] + public function submissionStatus(): object + { + $existing = $this->existingSubmission; + + if ($this->isResubmit) { + return (object) [ + 'type' => 'submitted', + 'badge_color' => 'success', + 'badge_label' => 'Sudah Dikumpulkan', + ]; + } + + return (object) [ + 'type' => 'none', + 'badge_color' => 'danger', + 'badge_label' => 'Tidak Mengumpulkan', + ]; + } + + public function getSubmissionFileUrl(): ?string + { + return $this->existingSubmission?->getFirstMediaUrl('submission'); + } + + public function submit(): void + { + $student = $this->student; + $assignment = $this->record; + + if ($assignment->type === AssignmentType::Group && ! $this->currentGroup) { + SystemNotification::danger( + 'Gagal Mengumpulkan ๐Ÿšซ', + 'Anda tidak terdaftar dalam kelompok manapun yang ditugaskan untuk tugas ini.' + )->send(); + + return; + } + + if (! $this->canSubmit) { + SystemNotification::danger( + 'Gagal Mengumpulkan ๐Ÿšซ', + 'Hanya ketua kelompok yang diizinkan untuk mengumpulkan atau memperbarui tugas kelompok.' + )->send(); + + return; + } + + $existingSubmission = $this->existingSubmission; + + if (! $existingSubmission && ! $this->file) { + SystemNotification::warning( + 'File Diperlukan', + 'Silakan pilih file untuk dikumpulkan.' + )->send(); + + return; + } + + $this->validate([ + 'file' => [ + 'nullable', + 'file', + 'max:'.(1024 * 5), + 'mimes:pdf', + ], + ], [ + 'file.max' => 'Ukuran file maksimal 5MB.', + 'file.mimes' => 'Hanya file PDF yang diizinkan.', + ]); + + if ($existingSubmission) { + if ($this->file) { + $existingSubmission->clearMediaCollection('submission'); + $existingSubmission->addMedia($this->file->getRealPath()) + ->usingName($this->file->getClientOriginalName()) + ->usingFileName($this->file->getClientOriginalName()) + ->toMediaCollection('submission'); + } + + $existingSubmission->update([ + 'submitted_at' => now(), + ]); + + SystemNotification::success( + 'Tugas Diperbarui โœ…', + 'File tugas Anda telah berhasil diunggah ulang dan diperbarui.' + )->send(); + } else { + $submission = AssignmentSubmission::create([ + 'assignment_id' => $assignment->id, + 'student_id' => $student->id, + 'study_group_id' => $assignment->type === AssignmentType::Group ? $this->currentGroup?->id : null, + 'submitted_at' => now(), + ]); + + $submission->addMedia($this->file->getRealPath()) + ->usingName($this->file->getClientOriginalName()) + ->usingFileName($this->file->getClientOriginalName()) + ->toMediaCollection('submission'); + + SystemNotification::success( + 'Tugas Dikumpulkan ๐Ÿš€', + 'Berhasil! Tugas Anda telah tercatat di sistem.' + )->send(); + } + + $this->file = null; + unset($this->existingSubmission, $this->isResubmit); + } + + protected function getHeaderActions(): array + { + return [ + BackAction::make() + ->url(AssignmentResource::getUrl()), + ]; + } +} diff --git a/app/Filament/Resources/Learning/Assignments/Schemas/AssignmentForm.php b/app/Filament/Resources/Learning/Assignments/Schemas/AssignmentForm.php new file mode 100644 index 0000000..ca0d003 --- /dev/null +++ b/app/Filament/Resources/Learning/Assignments/Schemas/AssignmentForm.php @@ -0,0 +1,128 @@ +components([ + Section::make('Informasi Tugas') + ->schema([ + + TextInput::make('title') + ->label('Judul') + ->placeholder('Tugas Pemrograman Web') + ->autocomplete(false) + ->required() + ->maxLength(100) + ->minLength(3) + ->autofocus() + ->columnSpanFull(), + + Grid::make(3) + ->schema([ + Select::make('course_id') + ->label('Mata Kuliah') + ->options(function () { + return Course::all() + ->groupBy('semester') + ->mapWithKeys(function ($courses, $semester) { + return [ + "Semester $semester" => $courses->pluck('name', 'id')->toArray(), + ]; + }) + ->toArray(); + }) + ->searchable() + ->live() + ->required() + ->optionsLimit(100), + + DateTimePicker::make('due_date') + ->label('Batas Waktu') + ->placeholder('Pilih Tanggal & Waktu') + ->required() + ->native(false) + ->displayFormat('l, d F Y H:i'), + + Select::make('type') + ->label('Tipe Tugas') + ->options(AssignmentType::class) + ->required() + ->native(false) + ->default(AssignmentType::Individual->value) + ->live() + ->afterStateUpdated(function (Set $set) { + $set('student_ids', []); + $set('study_group_ids', []); + }), + ]) + ->columnSpanFull(), + + RichEditor::make('description') + ->label('Deskripsi') + ->placeholder('Buatlah program web yang dapat melakukan ...') + ->nullable() + ->columnSpanFull(), + + AdvancedFileUpload::make('pdf') + ->label('Lampiran PDF') + ->pdfPreviewHeight(400) + ->pdfDisplayPage(1) + ->pdfToolbar(true) + ->pdfZoomLevel(100) + ->pdfFitType(PdfViewFit::FIT) + ->pdfNavPanes(true) + ->disk(config('filesystems.default')) + ->acceptedFileTypes(['application/pdf']) + ->maxSize(1024 * 5) + ->directory('assignments/'.now()->toDateString()) + ->nullable() + ->columnSpanFull(), + ]) + ->columns(2), + + Section::make('Target Penugasan') + ->schema([ + Select::make('student_ids') + ->label('Mahasiswa') + ->options(Student::pluck('full_name', 'id')) + ->multiple() + ->searchable() + ->preload() + ->native(false) + ->hintAction(SelectAllStudentsAction::make()) + ->helperText('Pilih mahasiswa yang menjadi target tugas ini.') + ->columnSpanFull() + ->hidden(fn (Get $get) => in_array($get('type'), [AssignmentType::Group, AssignmentType::Group->value])) + ->required(fn (Get $get) => ! in_array($get('type'), [AssignmentType::Group, AssignmentType::Group->value])), + + Callout::make('Penugasan Kelompok Otomatis') + ->description('Tugas ini akan otomatis ditujukan ke SEMUA kelompok belajar pada mata kuliah ini.') + ->info() + ->columnSpanFull() + ->visible(fn (Get $get) => in_array($get('type'), [AssignmentType::Group, AssignmentType::Group->value])), + ]), + ]) + ->columns(1); + } +} diff --git a/app/Models/Assignment.php b/app/Models/Assignment.php new file mode 100644 index 0000000..3f5b5e1 --- /dev/null +++ b/app/Models/Assignment.php @@ -0,0 +1,74 @@ + 'datetime', + 'type' => AssignmentType::class, + ]; + } + + public function assignmentPins(): HasMany + { + return $this->hasMany(AssignmentPin::class); + } + + public function assignmentSubmissions(): HasMany + { + return $this->hasMany(AssignmentSubmission::class); + } + + public function assignmentTargets(): HasMany + { + return $this->hasMany(AssignmentTarget::class); + } + + public function course(): BelongsTo + { + return $this->belongsTo(Course::class); + } + + public function students(): BelongsToMany + { + return $this->belongsToMany( + Student::class, + 'assignment_targets', + 'assignment_id', + 'student_id' + ); + } + + public function studyGroups(): BelongsToMany + { + return $this->belongsToMany( + StudyGroup::class, + 'assignment_targets', + 'assignment_id', + 'study_group_id' + ); + } +} diff --git a/app/Models/AssignmentPin.php b/app/Models/AssignmentPin.php new file mode 100644 index 0000000..9349c9f --- /dev/null +++ b/app/Models/AssignmentPin.php @@ -0,0 +1,24 @@ +belongsTo(Assignment::class); + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } +} diff --git a/app/Models/AssignmentSubmission.php b/app/Models/AssignmentSubmission.php new file mode 100644 index 0000000..fd4842d --- /dev/null +++ b/app/Models/AssignmentSubmission.php @@ -0,0 +1,44 @@ + 'datetime', + ]; + } + + public function assignment(): BelongsTo + { + return $this->belongsTo(Assignment::class); + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } + + public function studyGroup(): BelongsTo + { + return $this->belongsTo(StudyGroup::class); + } +} diff --git a/app/Models/AssignmentTarget.php b/app/Models/AssignmentTarget.php new file mode 100644 index 0000000..9717c29 --- /dev/null +++ b/app/Models/AssignmentTarget.php @@ -0,0 +1,33 @@ +belongsTo(Assignment::class); + } + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } + + public function studyGroup(): BelongsTo + { + return $this->belongsTo(StudyGroup::class); + } +} diff --git a/database/migrations/2026_02_25_024510_create_assignments_table.php b/database/migrations/2026_02_25_024510_create_assignments_table.php new file mode 100644 index 0000000..59246be --- /dev/null +++ b/database/migrations/2026_02_25_024510_create_assignments_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('course_id')->constrained()->cascadeOnDelete(); + $table->string('title', 100); + $table->text('description')->nullable(); + $table->timestamp('due_date'); + $table->enum('type', AssignmentType::cases())->default(AssignmentType::Individual); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('assignments'); + } +}; diff --git a/database/migrations/2026_02_25_024511_create_assignment_targets_table.php b/database/migrations/2026_02_25_024511_create_assignment_targets_table.php new file mode 100644 index 0000000..ef4b2eb --- /dev/null +++ b/database/migrations/2026_02_25_024511_create_assignment_targets_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('assignment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('student_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('study_group_id')->nullable()->constrained()->cascadeOnDelete(); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->nullable(); + + $table->unique(['assignment_id', 'student_id', 'study_group_id'], 'assign_target_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('assignment_targets'); + } +}; diff --git a/database/migrations/2026_02_25_024512_create_assignment_submissions_table.php b/database/migrations/2026_02_25_024512_create_assignment_submissions_table.php new file mode 100644 index 0000000..6e315be --- /dev/null +++ b/database/migrations/2026_02_25_024512_create_assignment_submissions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('assignment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('student_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('study_group_id')->nullable()->constrained()->cascadeOnDelete(); + $table->timestamp('submitted_at'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + + $table->unique(['assignment_id', 'student_id', 'study_group_id'], 'assign_sub_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('assignment_submissions'); + } +}; diff --git a/database/migrations/2026_03_03_083151_create_assignment_pins_table.php b/database/migrations/2026_03_03_083151_create_assignment_pins_table.php new file mode 100644 index 0000000..53d3d5c --- /dev/null +++ b/database/migrations/2026_03_03_083151_create_assignment_pins_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('student_id')->constrained()->cascadeOnDelete(); + $table->foreignId('assignment_id')->constrained()->cascadeOnDelete(); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + + $table->unique(['student_id', 'assignment_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('assignment_pins'); + } +}; diff --git a/resources/views/filament/resources/learning/assignments/pages/list-assignments.blade.php b/resources/views/filament/resources/learning/assignments/pages/list-assignments.blade.php new file mode 100644 index 0000000..84904bc --- /dev/null +++ b/resources/views/filament/resources/learning/assignments/pages/list-assignments.blade.php @@ -0,0 +1,151 @@ + + + Tugas Saya + Klik tugas untuk melihat detail dan mengumpulkan file. + + @if ($this->assignmentCards->isEmpty()) + + + @else +
+ @foreach ($this->assignmentCards as $card) +
+ $card->is_pinned, + 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800' => + !$card->is_pinned && $card->can_submit_actual, + 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/40 opacity-80' => !$card->can_submit_actual, + ])> + +
+ $card->is_submitted, + 'bg-danger-50 dark:bg-danger-900/20 text-danger-600 dark:text-danger-400' => + !$card->is_submitted && $card->is_overdue, + 'bg-warning-50 dark:bg-warning-900/20 text-warning-600 dark:text-warning-400' => + !$card->is_submitted && !$card->is_overdue, + ])> + +
+ +
+
+
+

+ {{ $card->title }}

+

+ {{ $card->course_name }} +

+
+ +
+ @if ($card->is_pinned) + + ๐Ÿ“Œ Dipinned + + @endif + @if ($card->is_new) + + โœจ Baru + + @endif + + @if ($card->is_group) + + ๐Ÿ‘ฅ Kelompok + + @endif + + + $card->status_color === 'amber', + 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400' => + $card->status_color === 'success', + 'bg-danger-100 text-danger-700 dark:bg-danger-900/30 dark:text-danger-400' => + $card->status_color === 'danger', + 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400' => + $card->status_color === 'warning', + 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400' => + $card->status_color === 'gray', + ])> + @if ($card->status_icon) + + @endif + {{ $card->status_label }} + + + @if ($card->is_urgent) + + ๐Ÿ”ฅ Segera! + + @endif +
+
+ +
+ + + Batas: {{ $card->due_date_formatted }} + + @if ($card->submitted_at_formatted) + + + Dikumpulkan: + {{ $card->submitted_at_formatted }} + + @endif + +
+ + +
+ + @if ($card->can_submit_actual) + + @endif +
+ +
+ + + + + +
+ + @if ($card->is_group && !$card->is_submitted) +
+ $card->is_leader, + 'text-gray-400' => !$card->is_leader, + ]) + title="{{ $card->is_leader ? 'Anda adalah Ketua Kelompok' : 'Anda adalah Anggota Kelompok' }}" /> +
+ @endif +
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/filament/resources/learning/assignments/pages/submission-detail.blade.php b/resources/views/filament/resources/learning/assignments/pages/submission-detail.blade.php new file mode 100644 index 0000000..a351456 --- /dev/null +++ b/resources/views/filament/resources/learning/assignments/pages/submission-detail.blade.php @@ -0,0 +1,125 @@ + +
+ @foreach ($this->statCards as $stat) +
+ +
+ +
+ +
+ + + {{ $stat['label'] }} + +
+ +

+ {{ $stat['value'] }} +

+
+ @endforeach +
+ +
+
+
+ + @php + $info = $this->getAssignmentInfo(); + @endphp + + + {{ $info['title'] }} + + {{ $info['course'] }} +  ยท  + Batas: {{ $info['due_date'] }} +  ยท  + Tipe: {{ $info['type'] }} + @if ($info['is_overdue']) + Terlewat + @endif + + + @if ($this->submissionSummary->isEmpty()) +
+ +

Tidak ada + {{ $this->record->type === \App\Enums\AssignmentType::Individual ? 'mahasiswa' : 'kelompok' }} yang + ditarget.

+
+ @else +
+ + + + + + + + + + + + + + @foreach ($this->submissionSummary as $item) + + + + + + + + + + + + + + + + @endforeach + +
No + {{ $this->record->type === \App\Enums\AssignmentType::Individual ? 'Mahasiswa' : 'Kelompok' }} + + {{ $this->record->type === \App\Enums\AssignmentType::Individual ? 'NIM' : 'Pengumpul' }} + StatusWaktu Kumpul + File
+ {{ $loop->iteration }} + {{ $item->primary_name }} + + {{ $item->secondary_info }} + + @if ($item->submitted) + + โœ“ Terkumpul + + @else + + Belum + + @endif + + {{ $item->submitted_at_formatted }} + + @if ($item->has_file) + + @else + - + @endif +
+
+ @endif +
+
diff --git a/resources/views/filament/resources/learning/assignments/pages/submit-assignment.blade.php b/resources/views/filament/resources/learning/assignments/pages/submit-assignment.blade.php new file mode 100644 index 0000000..46c07c2 --- /dev/null +++ b/resources/views/filament/resources/learning/assignments/pages/submit-assignment.blade.php @@ -0,0 +1,191 @@ + +
+ + + {{ $this->record->title }} + {{ $this->record->course?->name ?? '-' }} + +
+ @foreach ($this->statusCards as $card) +
+
+ $card['is_danger'] ?? false, + 'bg-success-50 dark:bg-success-900/20 text-success-600' => + $card['is_success'] ?? false, + 'bg-warning-50 dark:bg-warning-900/20 text-warning-600' => + !($card['is_danger'] ?? false) && !($card['is_success'] ?? false), + ])> + +
+
+

{{ $card['label'] }}

+

+ {{ $card['value'] }} + @if ($card['badge'] ?? null) + {{ $card['badge'] }} + @endif +

+
+
+ @endforeach +
+ + @if ($this->record->description) +
+ {!! $this->record->description !!} +
+ @endif + + @if ($this->record->hasMedia('assignments')) +
+

Lampiran Tugas (PDF)

+
+ +
+
+ @endif +
+ + @if ($this->isOverdue) + + Batas Waktu Telah Berakhir + Proses pengumpulan tugas ini telah ditutup karena melewati batas + waktu. + +
+
+ +
+
+ @php $status = $this->submissionStatus; @endphp + + {{ $status->badge_label }} + +
+
+ + @if ($url = $this->getSubmissionFileUrl()) +
+

File yang Dikumpulkan

+
+ +
+
+ @endif +
+ @elseif (!$this->canSubmit) + +
+ +

Tugas Kelompok

+

+ Hanya Ketua Kelompok yang dapat mengumpulkan atau memperbarui tugas ini. + Silakan hubungi ketua kelompok Anda. +

+
+ + @if ($url = $this->getSubmissionFileUrl()) +
+

File yang telah + dikumpulkan oleh kelompok Anda:

+
+ +
+
+ @endif +
+ @else + + {{ $this->isResubmit ? 'Perbarui Pengumpulan' : 'Kumpulkan Tugas' }} + + @if ($this->isResubmit && ($url = $this->getSubmissionFileUrl())) +
+

File Terkumpul Saat Ini

+
+ +
+
+ @endif + +
+
+ + +

+ Hanya file PDF. Maksimal 5MB. +

+ @error('file') +

+ {{ $message }}

+ @enderror +
+ + +
+ +
+ + + {{ $this->isResubmit ? 'Perbarui Pengumpulan' : 'Kumpulkan Sekarang' }} + + Mengupload... + +
+
+ @endif +
+
+ +@push('scripts') + +@endpush