feat: implement comprehensive assignment management system with models, migrations, Filament resources, and submission workflows.

This commit is contained in:
Yoga Pangestu 2026-03-11 13:34:54 +07:00
parent 2ab88d2931
commit aad22a8b59
21 changed files with 1908 additions and 0 deletions

View File

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasLabel;
enum AssignmentType: string implements HasLabel
{
case Individual = 'Individual';
case Group = 'Group';
public function getLabel(): ?string
{
return match ($this) {
self::Individual => 'Individu',
self::Group => 'Kelompok',
};
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\Learning\Assignments\Actions;
use App\Models\Student;
use Filament\Actions\Action;
use Filament\Schemas\Components\Utilities\Set;
class SelectAllStudentsAction extends Action
{
public static function getDefaultName(): ?string
{
return 'selectAll';
}
protected function setUp(): void
{
parent::setUp();
$this->label('Pilih Semua')
->action(function (Set $set) {
$studentIds = Student::pluck('id')->toArray();
$set('student_ids', $studentIds);
});
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Filament\Resources\Learning\Assignments\Actions;
use App\Enums\AssignmentType;
use App\Filament\Resources\Learning\Assignments\AssignmentResource;
use App\Models\Assignment;
use Filament\Actions\Action;
class SubmitAction extends Action
{
public static function getDefaultName(): ?string
{
return 'submit';
}
protected function setUp(): void
{
parent::setUp();
$this->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;
});
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Filament\Resources\Learning\Assignments;
use App\Filament\Resources\Learning\Assignments\Pages\CreateAssignment;
use App\Filament\Resources\Learning\Assignments\Pages\EditAssignment;
use App\Filament\Resources\Learning\Assignments\Pages\ListAssignments;
use App\Filament\Resources\Learning\Assignments\Pages\SubmissionDetailPage;
use App\Filament\Resources\Learning\Assignments\Pages\SubmitAssignmentPage;
use App\Filament\Resources\Learning\Assignments\Schemas\AssignmentForm;
use App\Models\Assignment;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use UnitEnum;
class AssignmentResource extends Resource
{
protected static ?string $model = Assignment::class;
protected static string|UnitEnum|null $navigationGroup = 'Pembelajaran';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedClipboardDocumentList;
protected static ?string $navigationLabel = 'Tugas';
protected static ?int $navigationSort = 5;
protected static ?string $recordTitleAttribute = 'title';
protected static ?string $slug = 'learning/assignments';
public static function form(Schema $schema): Schema
{
return AssignmentForm::configure($schema);
}
public static function getPages(): array
{
return [
'index' => ListAssignments::route('/'),
'create' => CreateAssignment::route('/create'),
'edit' => EditAssignment::route('/{record}/edit'),
'submission-detail' => SubmissionDetailPage::route('/{record}/submission-detail'),
'submit' => SubmitAssignmentPage::route('/{record}/submit'),
];
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Filament\Resources\Learning\Assignments\Pages;
use App\Enums\AssignmentType;
use App\Filament\Actions\BackAction;
use App\Filament\Resources\Learning\Assignments\AssignmentResource;
use App\Filament\Support\SystemNotification;
use App\Models\AssignmentTarget;
use App\Models\StudyGroup;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class CreateAssignment extends CreateRecord
{
protected static string $resource = AssignmentResource::class;
protected ?string $heading = 'Tambah Tugas';
protected static ?string $title = 'Tambah Tugas';
protected function getHeaderActions(): array
{
return [
BackAction::make()
->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;
});
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Filament\Resources\Learning\Assignments\Pages;
use App\Enums\AssignmentType;
use App\Filament\Actions\BackAction;
use App\Filament\Resources\Learning\Assignments\AssignmentResource;
use App\Filament\Support\SystemNotification;
use App\Models\AssignmentTarget;
use App\Models\StudyGroup;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class EditAssignment extends EditRecord
{
protected static string $resource = AssignmentResource::class;
protected ?string $heading = 'Ubah Tugas';
protected static ?string $title = 'Ubah Tugas';
protected function getHeaderActions(): array
{
return [
BackAction::make()
->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;
});
}
}

View File

@ -0,0 +1,259 @@
<?php
namespace App\Filament\Resources\Learning\Assignments\Pages;
use App\Enums\AssignmentType;
use App\Filament\Actions\Cheerful\CreateAction;
use App\Filament\Actions\Cheerful\DeleteAction;
use App\Filament\Actions\Cheerful\EditAction;
use App\Filament\Resources\Learning\Assignments\AssignmentResource;
use App\Filament\Resources\Learning\Assignments\Schemas\AssignmentForm;
use App\Filament\Support\SystemNotification;
use App\Models\Assignment;
use App\Models\AssignmentPin;
use App\Models\AssignmentTarget;
use App\Models\StudyGroup;
use Filament\Actions\Action;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
class ListAssignments extends Page implements HasTable
{
use InteractsWithTable;
protected static string $resource = AssignmentResource::class;
protected string $view = 'filament.resources.learning.assignments.pages.list-assignments';
protected static ?string $title = 'Tugas';
#[Computed]
public function assignments(): Collection
{
$studentProfile = auth()->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'),
];
}
}

View File

@ -0,0 +1,174 @@
<?php
namespace App\Filament\Resources\Learning\Assignments\Pages;
use App\Enums\AssignmentType;
use App\Filament\Actions\BackAction;
use App\Filament\Resources\Learning\Assignments\AssignmentResource;
use App\Models\Assignment;
use App\Models\AssignmentSubmission;
use Filament\Actions\Action;
use Filament\Resources\Pages\Page;
use Filament\Support\Enums\Width;
use Illuminate\Support\Collection;
use Joaopaulolndev\FilamentPdfViewer\Infolists\Components\PdfViewerEntry;
use Livewire\Attributes\Computed;
class SubmissionDetailPage extends Page
{
protected static string $resource = AssignmentResource::class;
protected string $view = 'filament.resources.learning.assignments.pages.submission-detail';
protected static ?string $title = 'Detail Rekap Pengumpulan';
public Assignment $record;
#[Computed]
public function statCards(): array
{
return [
[
'label' => $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()),
];
}
}

View File

@ -0,0 +1,231 @@
<?php
namespace App\Filament\Resources\Learning\Assignments\Pages;
use App\Enums\AssignmentType;
use App\Filament\Actions\BackAction;
use App\Filament\Resources\Learning\Assignments\AssignmentResource;
use App\Filament\Support\SystemNotification;
use App\Models\Assignment;
use App\Models\AssignmentSubmission;
use App\Models\Student;
use App\Models\StudyGroup;
use Filament\Resources\Pages\Page;
use Livewire\Attributes\Computed;
use Livewire\WithFileUploads;
class SubmitAssignmentPage extends Page
{
use WithFileUploads;
protected static string $resource = AssignmentResource::class;
protected string $view = 'filament.resources.learning.assignments.pages.submit-assignment';
protected static ?string $title = 'Kumpulkan Tugas';
public Assignment $record;
public $file = null;
#[Computed]
public function statusCards(): array
{
return [
[
'label' => '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()),
];
}
}

View File

@ -0,0 +1,128 @@
<?php
namespace App\Filament\Resources\Learning\Assignments\Schemas;
use App\Enums\AssignmentType;
use App\Filament\Resources\Learning\Assignments\Actions\SelectAllStudentsAction;
use App\Models\Course;
use App\Models\Student;
use Asmit\FilamentUpload\Enums\PdfViewFit;
use Asmit\FilamentUpload\Forms\Components\AdvancedFileUpload;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Callout;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
class AssignmentForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->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);
}
}

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

@ -0,0 +1,74 @@
<?php
namespace App\Models;
use App\Enums\AssignmentType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Assignment extends Model implements HasMedia
{
use HasFactory, InteractsWithMedia, SoftDeletes;
protected $fillable = [
'course_id',
'description',
'due_date',
'title',
'type',
];
protected function casts(): array
{
return [
'due_date' => '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'
);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AssignmentPin extends Model
{
protected $fillable = [
'assignment_id',
'student_id',
];
public function assignment(): BelongsTo
{
return $this->belongsTo(Assignment::class);
}
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Models;
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;
class AssignmentSubmission extends Model implements HasMedia
{
use HasFactory, InteractsWithMedia;
protected $fillable = [
'assignment_id',
'notes',
'student_id',
'study_group_id',
'submitted_at',
];
protected function casts(): array
{
return [
'submitted_at' => '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);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AssignmentTarget extends Model
{
use HasFactory;
protected $fillable = [
'assignment_id',
'student_id',
'study_group_id',
];
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);
}
}

View File

@ -0,0 +1,35 @@
<?php
use App\Enums\AssignmentType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('assignments', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('assignment_targets', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('assignment_submissions', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,27 @@
<?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('assignment_pins', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,151 @@
<x-filament-panels::page>
<x-filament::section>
<x-slot name="heading">Tugas Saya</x-slot>
<x-slot name="description">Klik tugas untuk melihat detail dan mengumpulkan file.</x-slot>
@if ($this->assignmentCards->isEmpty())
<x-filament::empty-state icon="heroicon-o-clipboard-document-list" heading="Tidak ada data yang ditemukan"
description="Setelah Anda membuat data pertama, maka akan muncul disini." iconColor="gray">
</x-filament::empty-state>
@else
<div class="space-y-3">
@foreach ($this->assignmentCards as $card)
<div @class([
'group flex w-full rounded-xl border transition-all overflow-hidden relative',
'border-primary-300 dark:border-primary-700 bg-primary-50/30 dark:bg-primary-900/10 shadow-sm' =>
$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,
])>
<a href="{{ \App\Filament\Resources\Learning\Assignments\AssignmentResource::getUrl('submit', ['record' => $card->id]) }}"
wire:navigate
class="flex flex-1 items-start gap-4 p-5 text-left min-w-0 hover:bg-primary-500/5 transition-colors cursor-pointer">
<div @class([
'flex h-11 w-11 shrink-0 items-center justify-center rounded-lg',
'bg-success-50 dark:bg-success-900/20 text-success-600 dark:text-success-400' =>
$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,
])>
<x-filament::icon :icon="$card->status_icon" class="h-6 w-6" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div>
<p class="font-semibold text-gray-900 dark:text-white">
{{ $card->title }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{{ $card->course_name }}
</p>
</div>
<div class="flex flex-wrap items-center gap-1.5">
@if ($card->is_pinned)
<span
class="inline-flex items-center gap-1 rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400 px-2.5 py-0.5 text-xs font-semibold">
📌 Dipinned
</span>
@endif
@if ($card->is_new)
<span
class="inline-flex items-center gap-1 rounded-full bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400 px-2.5 py-0.5 text-xs font-semibold">
Baru
</span>
@endif
@if ($card->is_group)
<span
class="inline-flex items-center gap-1 rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 px-2.5 py-0.5 text-xs font-semibold">
👥 Kelompok
</span>
@endif
<span @class([
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' =>
$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)
<x-filament::icon :icon="$card->status_icon" class="h-3 w-3" />
@endif
{{ $card->status_label }}
</span>
@if ($card->is_urgent)
<span
class="inline-flex items-center gap-1 rounded-full bg-danger-500 text-white px-2.5 py-0.5 text-xs font-semibold animate-pulse">
🔥 Segera!
</span>
@endif
</div>
</div>
<div
class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1">
<x-filament::icon icon="heroicon-o-clock" class="h-3.5 w-3.5" />
Batas: {{ $card->due_date_formatted }}
</span>
@if ($card->submitted_at_formatted)
<span class="flex items-center gap-1 text-success-600 dark:text-success-400">
<x-filament::icon icon="heroicon-o-arrow-up-tray" class="h-3.5 w-3.5" />
Dikumpulkan:
{{ $card->submitted_at_formatted }}
</span>
@endif
</div>
</div>
@if ($card->can_submit_actual)
<x-filament::icon icon="heroicon-o-chevron-right"
class="h-4 w-4 text-gray-400 group-hover:text-primary-500 mt-1 transition-colors shrink-0" />
@endif
</a>
<div
class="flex flex-col items-center justify-center border-l border-gray-100 dark:border-gray-700 p-1 gap-3 p-3">
<x-filament::icon-button wire:click="togglePin({{ $card->id }})" :icon="$card->is_pinned ? 'heroicon-s-bookmark' : 'heroicon-o-bookmark'"
:color="$card->is_pinned ? 'primary' : 'gray'" :tooltip="$card->is_pinned ? 'Lepas pin' : 'Pin tugas ini'" size="sm" />
<x-filament::icon-button
wire:click="mountAction('editAssignment', { record: {{ $card->id }} })"
icon="heroicon-m-pencil-square" color="warning" tooltip="Ubah" size="sm" />
<x-filament::icon-button
wire:click="mountAction('deleteAssignment', { record: {{ $card->id }} })"
icon="heroicon-m-trash" color="danger" tooltip="Hapus" size="sm" />
</div>
@if ($card->is_group && !$card->is_submitted)
<div class="absolute right-12 top-2 pointer-events-none">
<x-filament::icon
icon="{{ $card->is_leader ? 'heroicon-m-sparkles' : 'heroicon-m-user-group' }}"
@class([
'h-5 w-5 opacity-20',
'text-primary-500' => $card->is_leader,
'text-gray-400' => !$card->is_leader,
])
title="{{ $card->is_leader ? 'Anda adalah Ketua Kelompok' : 'Anda adalah Anggota Kelompok' }}" />
</div>
@endif
</div>
@endforeach
</div>
@endif
</x-filament::section>
</x-filament-panels::page>

View File

@ -0,0 +1,125 @@
<x-filament-panels::page>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
@foreach ($this->statCards as $stat)
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<div class="flex items-center gap-2 mb-1">
<div class="flex h-7 w-7 items-center justify-center rounded-lg {{ $stat['color'] }}">
<x-filament::icon :icon="$stat['icon']" class="h-4 w-4 text-current" />
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ $stat['label'] }}
</span>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ $stat['value'] }}
</p>
</div>
@endforeach
</div>
<div class="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-2">
<div class="h-2 rounded-full transition-all {{ $this->percentage === 100 ? 'bg-success-500' : ($this->percentage > 0 ? 'bg-warning-500' : 'bg-gray-300') }}"
style="width: {{ $this->percentage }}%"></div>
</div>
@php
$info = $this->getAssignmentInfo();
@endphp
<x-filament::section>
<x-slot name="heading">{{ $info['title'] }}</x-slot>
<x-slot name="description">
{{ $info['course'] }}
&nbsp;·&nbsp;
Batas: {{ $info['due_date'] }}
&nbsp;·&nbsp;
Tipe: {{ $info['type'] }}
@if ($info['is_overdue'])
<x-filament::badge color="danger" class="ml-2">Terlewat</x-filament::badge>
@endif
</x-slot>
@if ($this->submissionSummary->isEmpty())
<div class="flex flex-col items-center py-8 text-center">
<x-filament::icon icon="heroicon-o-user-group" class="w-10 h-10 text-gray-400 mb-3" />
<p class="text-gray-500 dark:text-gray-400">Tidak ada
{{ $this->record->type === \App\Enums\AssignmentType::Individual ? 'mahasiswa' : 'kelompok' }} yang
ditarget.</p>
</div>
@else
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/80 border-b border-gray-200 dark:border-gray-700">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-600 dark:text-gray-300">No</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 dark:text-gray-300">
{{ $this->record->type === \App\Enums\AssignmentType::Individual ? 'Mahasiswa' : 'Kelompok' }}
</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 dark:text-gray-300">
{{ $this->record->type === \App\Enums\AssignmentType::Individual ? 'NIM' : 'Pengumpul' }}
</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 dark:text-gray-300">Status</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 dark:text-gray-300">Waktu Kumpul
</th>
<th class="px-4 py-3 text-left font-semibold text-gray-600 dark:text-gray-300">File</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700 bg-white dark:bg-gray-900">
@foreach ($this->submissionSummary as $item)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 tabular-nums">
{{ $loop->iteration }}</td>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ $item->primary_name }}
</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 font-mono text-xs">
{{ $item->secondary_info }}
</td>
<td class="px-4 py-3">
@if ($item->submitted)
<span
class="inline-flex items-center gap-1 rounded-full bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400 px-2.5 py-0.5 text-xs font-medium">
Terkumpul
</span>
@else
<span
class="inline-flex items-center rounded-full bg-danger-100 text-danger-700 dark:bg-danger-900/30 dark:text-danger-400 px-2.5 py-0.5 text-xs font-medium">
Belum
</span>
@endif
</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
{{ $item->submitted_at_formatted }}
</td>
<td class="px-4 py-3">
@if ($item->has_file)
<button type="button"
wire:click="mountAction('previewSubmission', { submissionId: {{ $item->submission->id }} })"
class="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline">
<x-filament::icon icon="heroicon-o-eye" class="h-3.5 w-3.5" />
Lihat Tugas
</button>
@else
<span class="text-xs text-gray-400">-</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</x-filament::section>
</x-filament-panels::page>

View File

@ -0,0 +1,191 @@
<x-filament-panels::page>
<div class="space-y-5">
<x-filament::section>
<x-slot name="heading">{{ $this->record->title }}</x-slot>
<x-slot name="description">{{ $this->record->course?->name ?? '-' }}</x-slot>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
@foreach ($this->statusCards as $card)
<div class="flex items-center gap-3 rounded-lg border border-gray-200 dark:border-gray-700 p-3">
<div @class([
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
'bg-danger-50 dark:bg-danger-900/20 text-danger-600' =>
$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),
])>
<x-filament::icon :icon="$card['icon']" class="h-5 w-5" />
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $card['label'] }}</p>
<p
class="font-semibold text-sm {{ $card['is_danger'] ?? false ? 'text-danger-600 dark:text-danger-400' : 'text-gray-900 dark:text-white' }}">
{{ $card['value'] }}
@if ($card['badge'] ?? null)
<span class="text-xs font-normal">{{ $card['badge'] }}</span>
@endif
</p>
</div>
</div>
@endforeach
</div>
@if ($this->record->description)
<div
class="prose prose-sm dark:prose-invert max-w-none text-gray-600 dark:text-gray-400 border-t border-gray-100 dark:border-gray-700 pt-4">
{!! $this->record->description !!}
</div>
@endif
@if ($this->record->hasMedia('assignments'))
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Lampiran Tugas (PDF)</p>
<div
class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<iframe src="{{ $this->record->getFirstMediaUrl('assignments') }}" width="100%" height="500"
class="block border-0"></iframe>
</div>
</div>
@endif
</x-filament::section>
@if ($this->isOverdue)
<x-filament::section>
<x-slot name="heading">Batas Waktu Telah Berakhir</x-slot>
<x-slot name="description">Proses pengumpulan tugas ini telah ditutup karena melewati batas
waktu.</x-slot>
<div class="flex items-center gap-3 mb-5">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-danger-50 dark:bg-danger-900/20 text-danger-600">
<x-filament::icon icon="heroicon-o-lock-closed" class="h-5 w-5" />
</div>
<div class="flex flex-wrap items-center gap-2">
@php $status = $this->submissionStatus; @endphp
<x-filament::badge :color="$status->badge_color" size="lg">
{{ $status->badge_label }}
</x-filament::badge>
</div>
</div>
@if ($url = $this->getSubmissionFileUrl())
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">File yang Dikumpulkan</p>
<div
class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<iframe src="{{ $url }}" width="100%" height="500"
class="block border-0"></iframe>
</div>
</div>
@endif
</x-filament::section>
@elseif (!$this->canSubmit)
<x-filament::section>
<div
class="flex flex-col items-center justify-center py-8 text-center text-gray-500 dark:text-gray-400">
<x-filament::icon icon="heroicon-o-user-group" class="w-12 h-12 mb-3" />
<p class="font-semibold text-gray-700 dark:text-gray-300">Tugas Kelompok</p>
<p class="text-sm mt-1 max-w-sm">
Hanya <strong>Ketua Kelompok</strong> yang dapat mengumpulkan atau memperbarui tugas ini.
Silakan hubungi ketua kelompok Anda.
</p>
</div>
@if ($url = $this->getSubmissionFileUrl())
<div class="mt-6">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2 italic">File yang telah
dikumpulkan oleh kelompok Anda:</p>
<div
class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<iframe src="{{ $url }}" width="100%" height="400"
class="block border-0"></iframe>
</div>
</div>
@endif
</x-filament::section>
@else
<x-filament::section>
<x-slot name="heading">{{ $this->isResubmit ? 'Perbarui Pengumpulan' : 'Kumpulkan Tugas' }}</x-slot>
@if ($this->isResubmit && ($url = $this->getSubmissionFileUrl()))
<div class="mb-4">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">File Terkumpul Saat Ini</p>
<div
class="rounded-lg overflow-hidden border border-success-200 dark:border-success-800 bg-gray-50 dark:bg-gray-900">
<iframe src="{{ $url }}" width="100%" height="400"
class="block border-0"></iframe>
</div>
</div>
@endif
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{{ $this->isResubmit ? 'Ganti File PDF (opsional)' : 'File Tugas (PDF)' }}
@if (!$this->isResubmit)
<span class="text-danger-500">*</span>
@endif
</label>
<input wire:model="file" type="file" id="assignment-file" accept=".pdf"
class="block w-full text-sm text-gray-700 dark:text-gray-300
file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0
file:text-sm file:font-medium file:cursor-pointer
file:bg-primary-50 file:text-primary-700
dark:file:bg-primary-900/20 dark:file:text-primary-400
hover:file:bg-primary-100 dark:hover:file:bg-primary-900/30
border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-800 p-2 transition-colors" />
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Hanya file PDF. Maksimal 5MB.
</p>
@error('file')
<p class="mt-1 text-xs text-danger-600 dark:text-danger-400">
{{ $message }}</p>
@enderror
</div>
<div id="pdf-submission-preview" class="hidden" wire:ignore>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Preview</p>
<div
class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<iframe id="pdf-submission-frame" width="100%" height="400"
class="block border-0"></iframe>
</div>
</div>
</div>
<div class="flex justify-end mt-5 pt-4 border-t border-gray-200 dark:border-gray-700">
<x-filament::button wire:click="submit" wire:loading.attr="disabled" color="primary">
<span wire:loading.remove wire:target="submit">
{{ $this->isResubmit ? 'Perbarui Pengumpulan' : 'Kumpulkan Sekarang' }}
</span>
<span wire:loading wire:target="submit">Mengupload...</span>
</x-filament::button>
</div>
</x-filament::section>
@endif
</div>
</x-filament-panels::page>
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('change', function(e) {
if (e.target && e.target.id === 'assignment-file') {
var file = e.target.files[0];
var preview = document.getElementById('pdf-submission-preview');
var frame = document.getElementById('pdf-submission-frame');
if (file && preview && frame) {
frame.src = URL.createObjectURL(file);
preview.classList.remove('hidden');
} else if (preview) {
preview.classList.add('hidden');
}
}
});
});
</script>
@endpush