feat: Add course schedule model, migration, seeder, and Filament CRUD for managing schedules.

This commit is contained in:
Yoga Pangestu 2026-03-11 07:18:57 +07:00
parent 1c51ccd5b5
commit 7a9cb7fed9
11 changed files with 579 additions and 0 deletions

View File

@ -0,0 +1,32 @@
<?php
namespace App\Filament\Pages\Information\CourseSchedule\Actions;
use App\Filament\Actions\Cheerful\CreateAction;
use App\Models\CourseSchedule;
use Filament\Support\Enums\Width;
class CreateScheduleAction extends CreateAction
{
public static function getDefaultName(): ?string
{
return 'createSchedule';
}
protected function setUp(): void
{
parent::setUp();
$this->model(CourseSchedule::class)
->label('Tambah')
->modalHeading('Tambah Jadwal Kuliah')
->modalSubmitActionLabel('Simpan')
->modalCancelActionLabel('Batal')
->extraModalFooterActions(fn (CreateAction $action): array => [
$action->makeModalSubmitAction('createAnother', arguments: ['another' => true])
->label('Simpan dan Tambah Lagi'),
])
->modalWidth(Width::Large)
->schema(fn ($livewire) => $livewire->scheduleFormSchema());
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Filament\Pages\Information\CourseSchedule\Actions;
use App\Filament\Actions\Cheerful\DeleteAction;
use App\Models\CourseSchedule;
class DeleteScheduleAction extends DeleteAction
{
public static function getDefaultName(): ?string
{
return 'deleteSchedule';
}
protected function setUp(): void
{
parent::setUp();
$this->record(function (array $arguments): CourseSchedule {
return CourseSchedule::findOrFail($arguments['schedule']);
})
->modalHeading('Hapus Jadwal Kuliah')
->modalDescription('Apakah Anda yakin ingin menghapus jadwal ini? Tindakan ini tidak dapat dibatalkan.')
->modalSubmitActionLabel('Hapus')
->modalCancelActionLabel('Batal');
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Filament\Pages\Information\CourseSchedule\Actions;
use App\Filament\Actions\Cheerful\EditAction;
use App\Models\CourseSchedule;
use Filament\Support\Enums\Width;
class EditScheduleAction extends EditAction
{
public static function getDefaultName(): ?string
{
return 'editSchedule';
}
protected function setUp(): void
{
parent::setUp();
$this->record(function (array $arguments): CourseSchedule {
return CourseSchedule::findOrFail($arguments['schedule']);
})
->modalHeading('Ubah Jadwal Kuliah')
->modalSubmitActionLabel('Simpan')
->modalCancelActionLabel('Batal')
->modalWidth(Width::Large)
->schema(fn ($livewire) => $livewire->scheduleFormSchema());
}
}

View File

@ -0,0 +1,217 @@
<?php
namespace App\Filament\Pages\Information\CourseSchedule;
use App\Filament\Pages\Information\CourseSchedule\Actions\CreateScheduleAction;
use App\Filament\Pages\Information\CourseSchedule\Actions\DeleteScheduleAction;
use App\Filament\Pages\Information\CourseSchedule\Actions\EditScheduleAction;
use App\Models\Course;
use App\Models\CourseSchedule;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\TimePicker;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Collection;
use UnitEnum;
class Index extends Page implements HasActions, HasForms
{
use InteractsWithActions, InteractsWithForms;
protected static ?string $model = CourseSchedule::class;
protected static string|UnitEnum|null $navigationGroup = 'Informasi';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCalendarDays;
protected static ?string $title = 'Jadwal Kuliah';
protected static ?int $navigationSort = 1;
protected static ?string $recordTitleAttribute = 'day_of_week';
protected static ?string $slug = 'information/course-schedules';
protected string $view = 'filament.pages.information.list-course-schedules';
public ?int $course_semester = null;
public ?string $search = '';
public static function getPagePermission(): string
{
return 'View:CourseSchedule';
}
public function mount(): void
{
$this->course_semester = null;
$this->form->fill([
'course_semester' => $this->course_semester,
]);
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Select::make('course_semester')
->label('Semester')
->options(array_combine(range(1, 8), array_map(fn ($i) => "Semester $i", range(1, 8))))
->native(false)
->live()
->afterStateUpdated(fn ($state) => $this->course_semester = $state),
TextInput::make('search')
->label('Cari')
->placeholder('Cari Mata Kuliah atau Dosen...')
->autocomplete(false)
->live(debounce: 500)
->afterStateUpdated(fn ($state) => $this->search = $state),
])
->columns([
'sm' => 1,
'md' => 3,
]);
}
protected function getHeaderActions(): array
{
return [
CreateScheduleAction::make(),
];
}
protected function editScheduleAction(): Action
{
return EditScheduleAction::make();
}
protected function deleteScheduleAction(): Action
{
return DeleteScheduleAction::make();
}
public function getSchedules(): Collection
{
$user = auth()->user();
$lecturer = $user->lecturer;
$query = CourseSchedule::with(['course.lecturer']);
if ($lecturer) {
$query->whereHas('course', fn ($q) => $q->where('lecturer_id', $lecturer->id));
}
if ($this->course_semester) {
$query->whereHas('course', fn ($q) => $q->where('semester', $this->course_semester));
}
if ($this->search) {
$query->where(function ($q) {
$q->whereHas('course', fn ($sq) => $sq->where('name', 'like', "%{$this->search}%"))
->orWhereHas('course.lecturer', fn ($sq) => $sq->where('full_name', 'like', "%{$this->search}%"));
});
}
return $query->get()
->sortBy([
['day_of_week', 'asc'],
['start_time', 'asc'],
])
->groupBy('day_of_week');
}
protected function getViewData(): array
{
return [
'schedules' => $this->getSchedules(),
'days' => [
1 => 'Senin',
2 => 'Selasa',
3 => 'Rabu',
4 => 'Kamis',
5 => 'Jumat',
6 => 'Sabtu',
7 => 'Minggu',
],
];
}
public function scheduleFormSchema(): array
{
return [
Grid::make(2)
->schema([
Select::make('course_id')
->label('Mata Kuliah')
->options(function () {
$user = auth()->user();
$lecturer = $user->lecturer;
if ($lecturer) {
$courses = Course::getOptionsForLecturer($lecturer);
} else {
$courses = Course::all();
}
return $courses
->groupBy('semester')
->mapWithKeys(function ($courses, $semester) {
return [
"Semester $semester" => $courses->pluck('name', 'id')->toArray(),
];
})
->toArray();
})
->searchable()
->live()
->required()
->columnSpanFull(),
Grid::make(3)
->schema([
Select::make('day_of_week')
->label('Hari')
->placeholder('Pilih Hari')
->options([
1 => 'Senin',
2 => 'Selasa',
3 => 'Rabu',
4 => 'Kamis',
5 => 'Jumat',
6 => 'Sabtu',
7 => 'Minggu',
])
->native(false)
->required(),
TimePicker::make('start_time')
->label('Jam Mulai')
->placeholder('08:00')
->required()
->native(false)
->seconds(false),
TimePicker::make('end_time')
->label('Jam Selesai')
->placeholder('10:00')
->required()
->native(false)
->seconds(false),
])
->columnSpanFull(),
]),
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -17,4 +18,11 @@ public function lecturer(): BelongsTo
{
return $this->belongsTo(Lecturer::class);
}
public static function getOptionsForLecturer(Lecturer $lecturer): Collection
{
return static::query()
->where('lecturer_id', $lecturer->id)
->get();
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class CourseSchedule extends Model
{
use HasFactory, SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'end_time' => 'datetime:H:i',
'start_time' => 'datetime:H:i',
];
}
public function course(): BelongsTo
{
return $this->belongsTo(Course::class);
}
}

View File

@ -0,0 +1,35 @@
<?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('course_schedules', function (Blueprint $table) {
$table->id();
$table->foreignId('course_id')->constrained()->cascadeOnDelete();
$table->integer('day_of_week');
$table->time('start_time');
$table->time('end_time');
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
$table->index('course_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('course_schedules');
}
};

View File

@ -0,0 +1,34 @@
<?php
namespace Database\Seeders;
use App\Models\Course;
use App\Models\CourseSchedule;
use Illuminate\Database\Seeder;
class CourseScheduleSeeder extends Seeder
{
public function run(): void
{
$courses = Course::all();
$timeSlots = [
['08:00:00', '10:00:00'],
['10:00:00', '12:00:00'],
['13:00:00', '15:00:00'],
['15:00:00', '17:00:00'],
];
foreach ($courses as $course) {
$slot = $timeSlots[array_rand($timeSlots)];
CourseSchedule::create([
'course_id' => $course->id,
'day_of_week' => rand(1, 6),
'start_time' => $slot[0],
'end_time' => $slot[1],
]);
}
}
}

View File

@ -3,6 +3,7 @@
namespace Database\Seeders;
use App\Models\Course;
use App\Models\Lecturer;
use Illuminate\Database\Seeder;
class CourseSeeder extends Seeder
@ -83,6 +84,33 @@ public function run(): void
['code' => 'FSI839', 'name' => 'Startup Digital', 'credit' => 2, 'semester' => 8],
];
$lecturers = Lecturer::pluck('id')->toArray();
shuffle($lecturers);
$lecturerSemesterMap = [];
foreach ($courses as $i => &$course) {
$semester = $course['semester'];
if ($i < count($lecturers)) {
$lecturerId = $lecturers[$i];
} else {
do {
$lecturerId = $lecturers[array_rand($lecturers)];
} while (
isset($lecturerSemesterMap[$lecturerId]) &&
in_array($semester, $lecturerSemesterMap[$lecturerId])
);
}
$course['lecturer_id'] = $lecturerId;
$lecturerSemesterMap[$lecturerId][] = $semester;
}
Course::insert($courses);
}
}

View File

@ -18,6 +18,7 @@ public function run(): void
UserSeeder::class,
LecturerSeeder::class,
CourseSeeder::class,
CourseScheduleSeeder::class,
]);
}
}

View File

@ -0,0 +1,140 @@
<x-filament-panels::page>
<x-filament::section>
<div
class="fi-section-content rounded-xl border border-info-200 bg-info-50 dark:bg-info-500/10 dark:border-info-500/30 p-4 mb-6">
<div class="flex items-start gap-3">
<x-heroicon-o-information-circle class="w-5 h-5 text-info-600 dark:text-info-400 mt-0.5" />
<div class="text-sm text-info-800 dark:text-info-200">
Geser ke samping untuk melihat hari lainnya.
</div>
</div>
</div>
<div class="flex items-center justify-between gap-4 mb-6">
<div class="w-full">
{{ $this->form }}
</div>
</div>
@if ($schedules->isNotEmpty())
<div class="overflow-x-auto">
<div class="flex flex-nowrap gap-6 min-w-max pb-4">
@foreach ($days as $dayNumber => $dayName)
@php
$daySchedules = $schedules->get($dayNumber);
@endphp
@if ($daySchedules && $daySchedules->count() > 0)
<section class="w-80 shrink-0 space-y-4">
<div class="flex items-center gap-2 border-b border-gray-200 dark:border-gray-700 pb-2">
<x-heroicon-o-calendar class="w-5 h-5 text-primary-500" />
<h2 class="text-lg font-semibold tracking-tight text-gray-950 dark:text-white">
{{ $dayName }}
</h2>
</div>
<div class="space-y-4">
@foreach ($daySchedules as $schedule)
<div
class="fi-card rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm hover:shadow-md transition">
<div class="p-5 space-y-4">
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-md bg-primary-50 dark:bg-primary-500/10 px-2 py-1 text-xs font-semibold text-primary-700 dark:text-primary-300 ring-1 ring-inset ring-primary-600/20">
{{ $schedule->course->code ?? 'MATKUL' }}
</span>
@if ($schedule->mode)
@php
$color = $schedule->mode->getColor();
@endphp
<span
class="inline-flex items-center rounded-md bg-{{ $color }}-50 dark:bg-{{ $color }}-500/10 px-2 py-1 text-xs font-semibold text-{{ $color }}-700 dark:text-{{ $color }}-300 ring-1 ring-inset ring-{{ $color }}-600/20">
{{ $schedule->mode->getLabel() }}
</span>
@endif
</div>
@if ($schedule->room)
<span
class="inline-flex items-center text-xs text-gray-500 dark:text-gray-400">
<x-heroicon-m-map-pin class="w-4 h-4 mr-1 opacity-70" />
{{ $schedule->room }}
</span>
@endif
</div>
<h3
class="text-base font-semibold leading-tight text-gray-950 dark:text-white">
{{ $schedule->course->name ?? 'Mata Kuliah Tidak Diketahui' }}
</h3>
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<x-heroicon-m-user class="w-4 h-4 mr-1.5 opacity-70" />
<span class="truncate">
{{ $schedule->course->lecturer->full_name ?? 'Dosen Belum Ditentukan' }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2 mt-1">
@if ($schedule->course?->semester)
<span
class="inline-flex items-center gap-1 rounded-md bg-amber-50 dark:bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20">
<x-heroicon-m-academic-cap class="w-3 h-3" />
Semester {{ $schedule->course->semester }}
</span>
@endif
</div>
<div
class="flex items-center justify-between pt-3 border-t border-gray-100 dark:border-gray-800 text-sm">
<div
class="flex items-center font-medium text-gray-800 dark:text-gray-200">
<x-heroicon-o-clock class="w-4 h-4 mr-1.5 text-primary-500" />
{{ $schedule->start_time->format('H:i') }}
<span class="mx-1"></span>
{{ $schedule->end_time->format('H:i') }}
</div>
<span
class="text-[11px] font-semibold tracking-wide text-gray-400 uppercase">
{{ $schedule->course->credit ?? '-' }} SKS
</span>
</div>
<div
class="flex items-center justify-end gap-1 pt-3 border-t border-gray-100 dark:border-gray-800">
<x-filament::button size="xs" color="warning" tooltip="Ubah"
variant="primary"
wire:click="mountAction('editSchedule', { schedule: {{ $schedule->id }} })">
<x-heroicon-m-pencil-square class="w-4 h-4" />
</x-filament::button>
<x-filament::button size="xs" color="danger" tooltip="Hapus"
wire:click="mountAction('deleteSchedule', { schedule: {{ $schedule->id }} })">
<x-heroicon-m-trash class="w-4 h-4" />
</x-filament::button>
</div>
</div>
</div>
@endforeach
</div>
</section>
@endif
@endforeach
</div>
</div>
@endif
@if ($schedules->isEmpty())
<x-filament::empty-state icon="heroicon-o-calendar-days" heading="Tidak ada data yang ditemukan"
description="Setelah Anda membuat data pertama, maka akan muncul disini." iconColor="gray">
</x-filament::empty-state>
@endif
</x-filament::section>
</x-filament-panels::page>