feat: implement comprehensive assignment management system with models, migrations, Filament resources, and submission workflows.
This commit is contained in:
parent
2ab88d2931
commit
aad22a8b59
19
app/Enums/AssignmentType.php
Normal file
19
app/Enums/AssignmentType.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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
74
app/Models/Assignment.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
24
app/Models/AssignmentPin.php
Normal file
24
app/Models/AssignmentPin.php
Normal 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);
|
||||
}
|
||||
}
|
||||
44
app/Models/AssignmentSubmission.php
Normal file
44
app/Models/AssignmentSubmission.php
Normal 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);
|
||||
}
|
||||
}
|
||||
33
app/Models/AssignmentTarget.php
Normal file
33
app/Models/AssignmentTarget.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
@ -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'] }}
|
||||
·
|
||||
Batas: {{ $info['due_date'] }}
|
||||
·
|
||||
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>
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user