refactor: Remove legacy attendance management pages and integrate class session management functionality, including new models, migrations, and views for class sessions and attendance tracking.

This commit is contained in:
Yoga Pangestu 2026-03-29 20:40:54 +07:00
parent de1877695d
commit f0e6841628
21 changed files with 971 additions and 438 deletions

View File

@ -1,122 +0,0 @@
<?php
namespace App\Filament\Pages\Manage\Attendance;
use App\Filament\Actions\BackAction;
use App\Models\Attendance;
use App\Models\Course;
use App\Models\CourseSchedule;
use App\Models\Student;
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
use Filament\Pages\Page;
use Illuminate\Support\Collection;
class Detail extends Page
{
use HasPageShield;
public static function getPagePermission(): string
{
return 'View:AttendanceMonitoring';
}
protected static bool $shouldRegisterNavigation = false;
protected static ?string $title = 'Detail Monitoring Presensi';
protected static ?string $slug = 'manage/attendance-monitoring/{course}';
protected string $view = 'filament.pages.manage.attendance.detail';
public Course $course;
public function mount(Course $course): void
{
$this->course = $course;
}
public function getHeaderActions(): array
{
return [
BackAction::make()
->url(Index::getUrl()),
];
}
protected function getViewData(): array
{
return [
'selectedCourse' => $this->course,
'meetingHistory' => $this->getMeetingHistory(),
'activeMeetingStats' => $this->getActiveMeetingStats(),
];
}
private function getMeetingHistory(): Collection
{
return Attendance::query()
->whereHas('courseSchedule', function ($q) {
$q->where('course_id', $this->course->id);
})
->select('date')
->distinct()
->orderBy('date', 'desc')
->get()
->map(function ($att) {
$attendedCount = Attendance::whereHas('courseSchedule', function ($q) {
$q->where('course_id', $this->course->id);
})
->whereDate('date', $att->date)
->count();
$totalStudents = Student::count();
return (object) [
'date' => [
'month' => $att->date->translatedFormat('M'),
'day' => $att->date->translatedFormat('d'),
],
'formatted_date' => $att->date->translatedFormat('l, d F Y'),
'attended_count' => $attendedCount,
'total_students' => $totalStudents,
'share_url' => route('share.attendance', [
'token' => $this->course->sharing_token,
'date' => $att->date->toDateString(),
]),
];
});
}
private function getActiveMeetingStats(): ?object
{
$today = now()->dayOfWeekIso;
$nowTime = now()->format('H:i');
$activeSchedule = CourseSchedule::where('course_id', $this->course->id)
->where('day_of_week', $today)
->where('start_time', '<=', $nowTime)
->where('end_time', '>=', $nowTime)
->first();
if (! $activeSchedule) {
return null;
}
$attendedIds = Attendance::where('course_schedule_id', $activeSchedule->id)
->whereDate('date', now()->toDateString())
->pluck('student_id')
->toArray();
$totalStudents = Student::count();
$absentStudents = Student::whereNotIn('id', $attendedIds)->get();
return (object) [
'time_range' => $activeSchedule->start_time->format('H:i').' - '.$activeSchedule->end_time->format('H:i'),
'attended_count' => count($attendedIds),
'total_students' => $totalStudents,
'absent_students' => $absentStudents,
'percentage' => round((count($attendedIds) / $totalStudents) * 100),
];
}
}

View File

@ -1,65 +0,0 @@
<?php
namespace App\Filament\Pages\Manage\Attendance;
use App\Models\Course;
use App\Settings\GeneralSettings;
use BackedEnum;
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Collection;
use UnitEnum;
class Index extends Page
{
use HasPageShield;
public static function getPagePermission(): string
{
return 'View:AttendanceMonitoring';
}
protected static string|UnitEnum|null $navigationGroup = 'Kelola';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPresentationChartLine;
protected static ?string $navigationLabel = 'Monitoring Presensi';
protected static ?string $title = 'Monitoring Presensi';
protected static ?string $slug = 'manage/attendance-monitoring';
protected static ?int $navigationSort = 5;
protected string $view = 'filament.pages.manage.attendance.index';
protected function getViewData(): array
{
return [
'courses' => $this->getCourses(),
];
}
private function getCourses(): Collection
{
$semester = app(GeneralSettings::class)->current_semester;
$today = now()->dayOfWeekIso;
return Course::query()
->where('semester', $semester)
->with(['lecturer', 'courseSchedules'])
->get()
->map(function ($course) use ($today) {
$isOngoing = $course->courseSchedules->contains(function ($schedule) use ($today) {
return $schedule->day_of_week == $today;
});
$course->lecturer_name = $course->lecturer?->full_name;
$course->is_ongoing = $isOngoing;
$course->detail_url = Detail::getUrl(['course' => $course]);
return $course;
});
}
}

View File

@ -0,0 +1,226 @@
<?php
namespace App\Filament\Resources\Learning\ClassSessions;
use App\Enums\RoleEnum;
use App\Filament\Actions\Cheerful\DeleteAction;
use App\Filament\Actions\Cheerful\EditAction;
use App\Filament\Actions\Cheerful\ForceDeleteAction;
use App\Filament\Actions\Cheerful\RestoreAction;
use App\Filament\Actions\DefaultBulkActions;
use App\Filament\Columns\TimestampColumns;
use App\Filament\Resources\Learning\ClassSessions\Pages\ListCourseSessions;
use App\Filament\Resources\Learning\ClassSessions\Pages\ManageClassSessions;
use App\Filament\Resources\Learning\ClassSessions\RelationManagers\AttendanceRelationManager;
use App\Models\ClassSession;
use App\Models\Course;
use App\Settings\GeneralSettings;
use BackedEnum;
use Filament\Actions\BulkActionGroup;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\TimePicker;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use UnitEnum;
class ClassSessionResource extends Resource
{
protected static ?string $model = ClassSession::class;
protected static string|UnitEnum|null $navigationGroup = 'Pembelajaran';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPresentationChartBar;
protected static ?string $navigationLabel = 'Sesi Kelas';
protected static ?int $navigationSort = 1;
protected static ?string $modelLabel = 'Sesi Kelas';
protected static ?string $pluralModelLabel = 'Sesi Kelas';
public static function form(Schema $schema): Schema
{
return $schema
->components([
Grid::make(2)
->schema([
Select::make('course_id')
->label('Mata Kuliah')
->placeholder('Pilih Mata Kuliah')
->options(function () {
return Course::query()
->where('semester', app(GeneralSettings::class)->current_semester)
->pluck('name', 'id')
->toArray();
})
->searchable()
->required()
->preload()
->live()
->afterStateUpdated(function ($state, $set) {
if (! $state) {
return;
}
$schedule = \App\Models\CourseSchedule::where('course_id', $state)->first();
if ($schedule) {
$set('start_time', $schedule->start_time?->format('H:i'));
$set('end_time', $schedule->end_time?->format('H:i'));
}
// Ambil pertemuan terakhir + 1
$lastSession = \App\Models\ClassSession::where('course_id', $state)->max('session_number');
$set('session_number', ($lastSession ?? 0) + 1);
})
->columnSpanFull(),
TextInput::make('session_number')
->label('Pertemuan Ke-')
->placeholder('1')
->numeric()
->required()
->minValue(1)
->maxValue(16)
->autocomplete(false),
DatePicker::make('date')
->label('Tanggal')
->placeholder('Pilih Tanggal')
->required()
->native(false)
->displayFormat('l, d F Y')
->default(now()->toDateString()),
TimePicker::make('start_time')
->label('Waktu Mulai')
->placeholder('08:00')
->native(false)
->displayFormat('H:i')
->seconds(false)
->required(),
TimePicker::make('end_time')
->label('Waktu Selesai')
->placeholder('10:00')
->native(false)
->displayFormat('H:i')
->seconds(false)
->required(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('session_number')
->label('Pertemuan Ke-')
->sortable()
->badge(),
TextColumn::make('course.name')
->label('Mata Kuliah')
->searchable()
->sortable()
->wrap()
->description(fn (ClassSession $record) => $record->course->code),
TextColumn::make('date')
->label('Tanggal')
->date('l, d F Y')
->sortable()
->description(fn (ClassSession $record) => $record->start_time->format('H:i').' - '.$record->end_time->format('H:i').' WIB'),
TextColumn::make('attendances_count')
->counts('attendances')
->label('Presensi')
->badge()
->color('success'),
TextColumn::make('materials_count')
->counts('materials')
->label('Materi')
->badge()
->color('warning'),
TextColumn::make('assignments_count')
->counts('assignments')
->label('Tugas')
->badge()
->color('info'),
...TimestampColumns::make(),
])
->filters([
TrashedFilter::make()
->native(false)
->visible(fn () => auth()->user()->hasRole(RoleEnum::Developer)),
])
->recordActions([
EditAction::make()
->modalWidth(Width::TwoExtraLarge),
DeleteAction::make(),
ForceDeleteAction::make(),
RestoreAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
...DefaultBulkActions::make('Sesi Kelas'),
]),
])
->emptyStateIcon(Heroicon::OutlinedPresentationChartBar)
->emptyStateDescription('Setelah Anda membuat data pertama, maka akan muncul disini.')
->defaultSort('created_at', 'desc')
->deferFilters(false)
->paginated([25, 50, 100, 'all'])
->defaultPaginationPageOption(25);
}
public static function getRelations(): array
{
return [
AttendanceRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ManageClassSessions::route('/'),
'course' => ListCourseSessions::route('/course/{courseId}'),
];
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->whereHas('course', function (Builder $query) {
$query->where('semester', app(GeneralSettings::class)->current_semester);
});
}
public static function getRecordRouteBindingEloquentQuery(): Builder
{
return parent::getRecordRouteBindingEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Filament\Resources\Learning\ClassSessions\Pages;
use App\Filament\Actions\Cheerful\CreateAction;
use App\Filament\Actions\Cheerful\DeleteAction;
use App\Filament\Actions\Cheerful\EditAction;
use App\Filament\Resources\Learning\ClassSessions\ClassSessionResource;
use App\Models\ClassSession;
use App\Models\Course;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Resources\Pages\Page;
use Filament\Support\Enums\Width;
use Illuminate\Support\Collection;
class ListCourseSessions extends Page implements HasActions, HasForms
{
use InteractsWithActions, InteractsWithForms;
protected static string $resource = ClassSessionResource::class;
protected string $view = 'filament.resources.learning.class-sessions.pages.list-course-sessions';
public $courseId;
public function mount($courseId): void
{
$this->courseId = $courseId;
}
public function getCourse(): Course
{
return Course::findOrFail($this->courseId);
}
public function getSessions(): Collection
{
return $this->getCourse()->classSessions()->orderBy('session_number', 'asc')->get();
}
public function getTitle(): string
{
return 'Sesi Kelas - '.$this->getCourse()->name;
}
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Tambah Sesi')
->modalHeading('Tambah Sesi')
->modalSubmitActionLabel('Simpan')
->modalCancelActionLabel('Batal')
->mutateFormDataUsing(function (array $data): array {
$data['course_id'] = $this->courseId;
return $data;
})
->extraModalFooterActions(fn (CreateAction $action): array => [
$action->makeModalSubmitAction('createAnother', arguments: ['another' => true])
->label('Simpan dan Tambah Lagi'),
])
->modalWidth(Width::TwoExtraLarge),
];
}
public function editSessionAction(): Action
{
return EditAction::make('editSession')
->record(fn (array $arguments) => ClassSession::find($arguments['session']))
->modalWidth(Width::TwoExtraLarge);
}
public function deleteSessionAction(): Action
{
return DeleteAction::make('deleteSession')
->record(fn (array $arguments) => ClassSession::find($arguments['session']));
}
}

View File

@ -0,0 +1,159 @@
<?php
namespace App\Filament\Resources\Learning\ClassSessions\Pages;
use App\Filament\Actions\Cheerful\DeleteAction;
use App\Filament\Actions\Cheerful\EditAction;
use App\Filament\Resources\Learning\ClassSessions\ClassSessionResource;
use App\Models\ClassSession;
use App\Models\Course;
use App\Models\CourseSchedule;
use App\Settings\GeneralSettings;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Resources\Pages\Page;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Illuminate\Support\Collection;
class ManageClassSessions extends Page implements HasActions, HasForms
{
use InteractsWithActions, InteractsWithForms;
public static string $resource = ClassSessionResource::class;
protected static ?string $title = 'Sesi Kelas';
protected string $view = 'filament.pages.learning.class-session.index';
public ?string $search = '';
public function mount(): void
{
$this->form->fill();
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
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,
]);
}
protected function getHeaderActions(): array
{
return [];
}
public function editSessionAction(): Action
{
return EditAction::make('editSession')
->record(fn (array $arguments) => ClassSession::find($arguments['session']))
->modalWidth(Width::TwoExtraLarge);
}
public function deleteSessionAction(): Action
{
return DeleteAction::make('deleteSession')
->record(fn (array $arguments) => ClassSession::find($arguments['session']));
}
public function generateTodaySessionAction(): Action
{
return Action::make('generateTodaySession')
->label('Generate Sesi')
->icon('heroicon-o-sparkles')
->color('primary')
->requiresConfirmation()
->modalHeading('Generate Sesi Hari Ini?')
->modalDescription('Sistem akan membuat sesi baru secara otomatis berdasarkan jadwal aktif.')
->modalSubmitActionLabel('Generate Sekarang')
->action(function (array $arguments) {
$courseId = $arguments['course'];
$schedule = CourseSchedule::where('course_id', $courseId)->first();
if (! $schedule) {
\Filament\Notifications\Notification::make()
->title('Jadwal Tidak Ditemukan')
->danger()
->send();
return;
}
$lastSession = ClassSession::where('course_id', $courseId)->max('session_number');
ClassSession::create([
'course_id' => $courseId,
'session_number' => ($lastSession ?? 0) + 1,
'date' => now()->toDateString(),
'start_time' => $schedule->start_time,
'end_time' => $schedule->end_time,
]);
\Filament\Notifications\Notification::make()
->title('Sesi Berhasil Digenerate')
->success()
->send();
});
}
public function getTodaySessions(): Collection
{
$dayOfWeek = now()->dayOfWeekIso; // 1 (Mon) - 7 (Sun)
$schedules = CourseSchedule::query()
->where('day_of_week', $dayOfWeek)
->whereHas('course', function ($query) {
$query->where('semester', app(GeneralSettings::class)->current_semester);
})
->with(['course.lecturer', 'course.classSessions' => fn ($q) => $q->whereDate('date', now())])
->orderBy('start_time', 'asc')
->get();
return $schedules->map(function ($schedule) {
$session = $schedule->course->classSessions->first();
return (object) [
'id' => $session?->id,
'course' => $schedule->course,
'session_number' => $session?->session_number ?? (ClassSession::where('course_id', $schedule->course_id)->max('session_number') ?? 0) + 1,
'start_time' => $schedule->start_time,
'end_time' => $schedule->end_time,
'title' => $session?->title,
'is_pending' => ! $session,
];
});
}
public function getCourses(): Collection
{
$query = Course::query()
->where('semester', app(GeneralSettings::class)->current_semester)
->with(['classSessions' => fn ($q) => $q->orderBy('session_number', 'asc'), 'lecturer']);
if ($this->search) {
$query->where(function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('code', 'like', "%{$this->search}%")
->orWhereHas('lecturer', fn ($sq) => $sq->where('full_name', 'like', "%{$this->search}%"));
});
}
return $query->get()
->sortBy('name');
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Filament\Resources\Learning\ClassSessions\RelationManagers;
use App\Filament\Actions\Cheerful\CreateAction;
use App\Filament\Actions\Cheerful\DeleteAction;
use App\Filament\Actions\Cheerful\EditAction;
use App\Filament\Actions\DefaultBulkActions;
use Filament\Actions\BulkActionGroup;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Filament\Tables;
use Filament\Tables\Table;
class AttendanceRelationManager extends RelationManager
{
protected static string $relationship = 'attendances';
protected static ?string $title = 'Presensi Mahasiswa';
public function form(Schema $schema): Schema
{
return $schema
->components([
Select::make('student_id')
->label('Mahasiswa')
->placeholder('Pilih Mahasiswa')
->relationship('student', 'full_name')
->searchable()
->required()
->preload()
->helperText('Pilih mahasiswa yang hadir pada pertemuan ini.'),
DateTimePicker::make('attended_at')
->label('Waktu Presensi')
->default(now())
->native(false)
->helperText('Tanggal dan jam mahasiswa melakukan presensi.'),
])
->columns(1);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('student.full_name')
->columns([
Tables\Columns\TextColumn::make('student.student_number')
->label('NIM')
->copyable()
->searchable()
->sortable()
->color('gray'),
Tables\Columns\TextColumn::make('student.full_name')
->label('Nama Mahasiswa')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('attended_at')
->label('Waktu Presensi')
->dateTime('l, d F Y H:i')
->sortable()
->description(fn ($record) => $record->attended_at->format('H:i').' WIB')
->color('primary'),
])
->filters([
//
])
->headerActions([
CreateAction::make()
->modalWidth(Width::Large),
])
->actions([
EditAction::make()
->modalWidth(Width::Large),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
...DefaultBulkActions::make('Presensi Mahasiswa'),
]),
])
->emptyStateDescription('Belum ada data presensi untuk pertemuan ini.');
}
}

View File

@ -18,6 +18,7 @@ class Assignment extends Model implements HasMedia
protected $fillable = [
'course_id',
'class_session_id',
'description',
'due_date',
'title',
@ -52,6 +53,11 @@ public function course(): BelongsTo
return $this->belongsTo(Course::class);
}
public function classSession(): BelongsTo
{
return $this->belongsTo(ClassSession::class);
}
public function students(): BelongsToMany
{
return $this->belongsToMany(

View File

@ -25,4 +25,9 @@ public function courseSchedule()
{
return $this->belongsTo(CourseSchedule::class);
}
public function classSession()
{
return $this->belongsTo(ClassSession::class);
}
}

View File

@ -0,0 +1,49 @@
<?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\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ClassSession extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'course_id',
'session_number',
'date',
'start_time',
'end_time',
];
protected $casts = [
'date' => 'date',
'session_number' => 'integer',
'start_time' => 'datetime',
'end_time' => 'datetime',
];
public function course(): BelongsTo
{
return $this->belongsTo(Course::class);
}
public function materials(): HasMany
{
return $this->hasMany(Material::class);
}
public function assignments(): HasMany
{
return $this->hasMany(Assignment::class);
}
public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
}
}

View File

@ -49,4 +49,9 @@ public function courseSchedules(): HasMany
{
return $this->hasMany(CourseSchedule::class);
}
public function classSessions(): HasMany
{
return $this->hasMany(ClassSession::class);
}
}

View File

@ -23,4 +23,9 @@ public function course(): BelongsTo
{
return $this->belongsTo(Course::class);
}
public function classSession(): BelongsTo
{
return $this->belongsTo(ClassSession::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('class_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('course_id')->constrained()->cascadeOnDelete();
$table->unsignedTinyInteger('session_number'); // e.g., 1, 2, ...
$table->date('date');
$table->time('start_time');
$table->time('end_time');
$table->timestamps();
$table->softDeletes();
$table->unique(['course_id', 'session_number'], 'class_sessions_course_session_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('class_sessions');
}
};

View File

@ -15,6 +15,7 @@ public function up(): void
Schema::create('assignments', function (Blueprint $table) {
$table->id();
$table->foreignId('course_id')->constrained()->cascadeOnDelete();
$table->foreignId('class_session_id')->nullable()->constrained()->nullOnDelete();
$table->string('title', 100);
$table->text('description')->nullable();
$table->timestamp('due_date');

View File

@ -14,12 +14,14 @@ public function up(): void
Schema::create('attendances', function (Blueprint $table) {
$table->id();
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
$table->foreignId('course_schedule_id')->constrained()->cascadeOnDelete();
$table->date('date');
$table->foreignId('class_session_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('course_schedule_id')->nullable()->constrained()->cascadeOnDelete();
$table->date('date')->nullable();
$table->timestamp('attended_at')->nullable();
$table->timestamps();
$table->unique(['student_id', 'course_schedule_id', 'date']);
$table->unique(['student_id', 'class_session_id']);
$table->index(['student_id', 'course_schedule_id', 'date']);
});
}

View File

@ -14,6 +14,7 @@ public function up(): void
Schema::create('materials', function (Blueprint $table) {
$table->id();
$table->foreignId('course_id')->constrained()->cascadeOnDelete();
$table->foreignId('class_session_id')->nullable()->constrained()->nullOnDelete();
$table->string('title', 100);
$table->text('description')->nullable();
$table->timestamp('published_at')->nullable();

View File

@ -0,0 +1,139 @@
<x-filament-panels::page>
<!-- Search Section -->
<x-filament::section>
<div class="flex items-center justify-between gap-4">
<div class="w-full max-w-xl">
{{ $this->form }}
</div>
</div>
</x-filament::section>
<!-- Today's Sessions Section -->
@if ($this->getTodaySessions()->isNotEmpty())
<x-filament::section icon="heroicon-o-bolt" icon-color="primary">
<x-slot name="heading">
Sesi Hari Ini
</x-slot>
<x-slot name="description">
Sesi yang dijadwalkan pada {{ now()->format('l, d F Y') }}
</x-slot>
<div class="space-y-4">
@foreach ($this->getTodaySessions() as $session)
<div @class([
'group flex w-full rounded-xl border overflow-hidden relative transition-all hover:shadow-md',
'border-primary-300 dark:border-primary-700 bg-primary-50/30 dark:bg-primary-900/10 shadow-sm' => !$session->is_pending,
'border-gray-300 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800/10 border-dashed' => $session->is_pending,
])>
<div class="flex flex-1 items-start gap-4 p-5">
<div @class([
'flex h-12 w-12 shrink-0 flex-col items-center justify-center rounded-lg font-bold border',
'bg-primary-100 dark:bg-primary-800 text-primary-700 dark:text-primary-300 border-primary-200 dark:border-primary-700' => !$session->is_pending,
'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700' => $session->is_pending,
])>
<span class="text-[9px] uppercase leading-none opacity-60 mb-0.5 font-bold">Sesi</span>
<span class="text-xl leading-none italic">#{{ $session->session_number }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div>
<h3 @class([
'font-bold transition-colors',
'text-gray-900 dark:text-white group-hover:text-primary-600' => !$session->is_pending,
'text-gray-500 dark:text-gray-400' => $session->is_pending,
])>
{{ $session->course->name }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5 font-medium">
{{ $session->course->code }} {{ $session->course->lecturer->full_name ?? '-' }}
</p>
</div>
<div @class([
'flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-bold shadow-sm',
'bg-primary-500 text-white animate-pulse' => !$session->is_pending,
'bg-gray-500 text-white opacity-60' => $session->is_pending,
])>
<x-heroicon-s-clock class="w-3.5 h-3.5" />
{{ $session->start_time->format('H:i') }} - {{ $session->end_time->format('H:i') }}
</div>
@if ($session->is_pending)
<div class="mt-2 flex items-center gap-1.5 text-[10px] text-gray-400 dark:text-gray-500 font-medium italic">
<x-heroicon-m-sparkles class="w-3.5 h-3.5" />
Sesi belum digenerate hari ini
</div>
@endif
</div>
</div>
<div @class([
'flex flex-col items-center justify-center border-l p-2 bg-white/30 dark:bg-black/10 transition-colors',
'border-primary-200 dark:border-primary-800 group-hover:bg-primary-500/5' => !$session->is_pending,
'border-gray-200 dark:border-gray-800' => $session->is_pending,
])>
@if ($session->is_pending)
{{ ($this->generateTodaySessionAction)(['course' => $session->course->id]) }}
@else
{{ ($this->editSessionAction)(['session' => $session->id]) }}
@endif
</div>
</div>
</div>
@endforeach
</div>
</x-filament::section>
@endif
<!-- All Courses Section -->
<x-filament::section icon="heroicon-o-academic-cap" icon-color="gray">
<x-slot name="heading">
Daftar Mata Kuliah Semester Ini
</x-slot>
<x-slot name="description">
Pilih mata kuliah untuk melihat dan mengelola semua riwayat sesi.
</x-slot>
@if ($this->getCourses()->isNotEmpty())
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach ($this->getCourses() as $course)
<a href="{{ \App\Filament\Resources\Learning\ClassSessions\ClassSessionResource::getUrl('course', ['courseId' => $course->id]) }}"
class="fi-card rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm overflow-hidden transition hover:shadow-md h-fit block group">
<div class="p-5 space-y-4">
<div class="flex items-center justify-between">
<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">
{{ $course->code }}
</span>
<div class="flex items-center gap-2 text-gray-400 dark:text-gray-500">
<span class="text-xs font-medium">
{{ $course->classSessions->count() }} Sesi
</span>
<x-heroicon-m-chevron-right
class="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</div>
</div>
<div class="space-y-1">
<h3
class="text-base font-bold text-gray-950 dark:text-white leading-tight group-hover:text-primary-600 dark:group-hover:text-primary-400 transition">
{{ $course->name }}
</h3>
<div class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<x-heroicon-m-user class="w-4 h-4 mr-1.5 opacity-70" />
<span
class="truncate">{{ $course->lecturer->full_name ?? 'Dosen Belum Ditentukan' }}</span>
</div>
</div>
</div>
</a>
@endforeach
</div>
@else
<x-filament::empty-state icon="heroicon-o-presentation-chart-bar" 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>

View File

@ -1,145 +0,0 @@
<x-filament-panels::page>
<div class="space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left: History -->
<div class="lg:col-span-2 space-y-6">
<x-filament::section>
<x-slot name="heading">Riwayat Pertemuan</x-slot>
<x-slot name="description">Rekapitulasi presensi tiap pertemuan.</x-slot>
<div class="space-y-4">
@forelse ($meetingHistory as $meeting)
<div
class="fi-card flex items-center justify-between p-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm transition duration-200 hover:shadow-md hover:border-primary-500">
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-lg bg-gray-50 dark:bg-gray-800 flex flex-col items-center justify-center border border-gray-100 dark:border-gray-700">
<span
class="text-[10px] font-bold uppercase text-gray-400">{{ $meeting->date['month'] }}</span>
<span
class="text-xl font-bold text-gray-700 dark:text-gray-200">{{ $meeting->date['day'] }}</span>
</div>
<div>
<h4 class="font-bold text-sm text-gray-950 dark:text-white">
{{ $meeting->formatted_date }}</h4>
<p
class="text-[11px] font-medium text-gray-500 uppercase tracking-tight mt-0.5">
HADIR: <span
class="text-primary-600 dark:text-primary-400">{{ $meeting->attended_count }}
/ {{ $meeting->total_students }}</span>
</p>
</div>
</div>
<x-filament::button
x-on:click="window.navigator.clipboard.writeText('{{ $meeting->share_url }}'); new FilamentNotification().title('Tautan Berhasil Disalin ✨').body('Tautan presensi telah berhasil disalin ke papan klip.').success().send()"
color="gray" size="xs" icon="heroicon-m-share"
class="rounded-lg shadow-sm font-bold uppercase tracking-tight text-[10px]">
Salin Link
</x-filament::button>
</div>
@empty
<x-filament::empty-state icon="heroicon-o-calendar" heading="Belum Ada Riwayat Pertemuan"
description="Riwayat pertemuan untuk mata kuliah ini belum tersedia. Data presensi akan muncul setelah sesi perkuliahan dilakukan."
iconColor="gray">
</x-filament::empty-state>
@endforelse
</div>
</x-filament::section>
</div>
<!-- Right: Ongoing Stats -->
<div class="space-y-6">
@if ($activeMeetingStats)
<div
class="fi-card flex flex-col justify-between rounded-xl border transition duration-200 relative bg-primary-50/30 dark:bg-primary-900/10 border-primary-500 shadow-md ring-1 ring-primary-500">
<div class="p-6 space-y-4">
<div class="space-y-1">
<h3
class="text-lg font-bold leading-tight flex items-center gap-2 text-primary-900 dark:text-primary-100">
<x-heroicon-o-presentation-chart-line class="w-5 h-5 shrink-0 text-primary-500" />
Monitoring Sesi Ini
</h3>
</div>
<div class="space-y-4 pt-4 border-t border-primary-200 dark:border-primary-800">
<div
class="flex justify-between items-center text-[10px] font-bold uppercase tracking-widest text-primary-700 dark:text-primary-300">
<span>WAKTU PERKULIAHAN</span>
<span
class="font-mono bg-primary-100 dark:bg-primary-800 px-2 py-0.5 rounded text-primary-600 dark:text-primary-400">{{ $activeMeetingStats->time_range }}</span>
</div>
<div class="space-y-3">
<div class="flex justify-between items-end">
<div
class="text-3xl font-black text-primary-950 dark:text-white tracking-tight">
{{ $activeMeetingStats->attended_count }} <span
class="text-sm font-normal text-primary-600/70 italic">/
{{ $activeMeetingStats->total_students }}</span>
</div>
<span
class="text-xs font-bold text-primary-600 dark:text-primary-400 bg-primary-100 dark:bg-primary-800 px-2 py-1 rounded-lg">
{{ $activeMeetingStats->percentage }}%
</span>
</div>
<div
class="w-full h-3 bg-white/50 dark:bg-gray-800/50 rounded-full overflow-hidden border border-primary-200 dark:border-primary-700 p-0.5">
<div class="h-full bg-primary-500 rounded-full transition-all duration-1000 shadow-[0_0_12px_rgba(var(--primary-500),0.4)]"
style="width: {{ $activeMeetingStats->percentage }}%">
</div>
</div>
</div>
<div class="pt-4 border-t border-primary-200 dark:border-primary-800">
<h5
class="text-[10px] font-bold text-primary-700 dark:text-primary-300 uppercase tracking-widest mb-3 flex items-center justify-between">
<span>Belum Melakukan Absen</span>
<span
class="bg-warning-500 text-white px-2 py-0.5 rounded text-[10px] shadow-sm font-bold">{{ $activeMeetingStats->total_students - $activeMeetingStats->attended_count }}</span>
</h5>
<div class="max-h-[350px] overflow-y-auto space-y-2 thin-scrollbar pr-1">
@foreach ($activeMeetingStats->absent_students as $absent)
<div
class="p-3 rounded-xl bg-white/60 dark:bg-gray-900/60 border border-primary-200/50 dark:border-primary-800/50 text-xs shadow-sm group hover:border-warning-500 transition">
<div class="font-bold text-gray-950 dark:text-white truncate">
{{ $absent->full_name }}</div>
<div class="text-[10px] text-gray-500 font-mono mt-0.5">
{{ $absent->nim }}
</div>
</div>
@endforeach
</div>
</div>
</div>
</div>
</div>
@else
<x-filament::empty-state icon="heroicon-o-clock" heading="Tidak Ada Sesi Perkuliahan Aktif"
description="Saat ini belum ada sesi perkuliahan yang berlangsung. Silakan lihat daftar riwayat pertemuan di sebelah kiri."
iconColor="gray">
</x-filament::empty-state>
@endif
</div>
</div>
</div>
<style>
.thin-scrollbar::-webkit-scrollbar {
width: 4px;
}
.thin-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.05);
border-radius: 10px;
}
.dark .thin-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.05);
}
</style>
</x-filament-panels::page>

View File

@ -1,102 +0,0 @@
<x-filament-panels::page>
<div class="space-y-6">
<x-filament::section>
<x-slot name="heading">Monitoring Presensi</x-slot>
<x-slot name="description">Pilih mata kuliah untuk memantau kehadiran mahasiswa per pertemuan.</x-slot>
<div class="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6">
@foreach ($courses as $course)
<a href="{{ $course->detail_url }}" class="break-inside-avoid mb-6 block group">
<div @class([
'fi-card flex flex-col justify-between rounded-xl border transition duration-200 group relative',
'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md hover:border-primary-500' => !$course->is_ongoing,
'bg-primary-50/30 dark:bg-primary-900/10 border-primary-500 shadow-md ring-1 ring-primary-500' =>
$course->is_ongoing,
])>
@if ($course->is_ongoing)
<div class="absolute top-0 right-0 -translate-x-1 -translate-y-1/2 z-10">
<span
class="inline-flex items-center gap-1 rounded-full bg-primary-600 px-3 py-1 text-[10px] font-bold text-white shadow-lg uppercase tracking-widest">
<div class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></div>
Hari Ini
</span>
</div>
@endif
<div class="p-6 space-y-4 flex-1">
<div class="space-y-1">
<h3 @class([
'text-lg font-bold leading-tight flex items-center gap-2 transition-colors',
'text-gray-950 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400' => !$course->is_ongoing,
'text-primary-900 dark:text-primary-100' => $course->is_ongoing,
])>
<x-heroicon-o-folder @class([
'w-5 h-5 shrink-0',
'opacity-40' => !$course->is_ongoing,
'text-primary-500' => $course->is_ongoing,
]) />
{{ $course->name }}
</h3>
<div @class([
'flex items-center gap-1.5',
'text-gray-500 dark:text-gray-400' => !$course->is_ongoing,
'text-primary-600 dark:text-primary-400' => $course->is_ongoing,
])>
<x-heroicon-m-hashtag @class(['w-3.5 h-3.5 shrink-0', 'opacity-50' => !$course->is_ongoing]) />
<span class="text-[11px] font-medium leading-none uppercase tracking-widest">
{{ $course->code }}
</span>
</div>
</div>
<div @class([
'pt-4 border-t',
'border-gray-100 dark:border-gray-800' => !$course->is_ongoing,
'border-primary-200 dark:border-primary-800' => $course->is_ongoing,
])>
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<div @class([
'w-8 h-8 rounded-full flex items-center justify-center mr-3 shrink-0 border',
'bg-primary-100 dark:bg-primary-900/40 border-primary-200 dark:border-primary-800' => !$course->is_ongoing,
'bg-white dark:bg-primary-800 border-primary-300 dark:border-primary-600' =>
$course->is_ongoing,
])>
<x-heroicon-m-user @class([
'w-4 h-4',
'text-primary-600 dark:text-primary-300' => !$course->is_ongoing,
'text-primary-700 dark:text-primary-200' => $course->is_ongoing,
]) />
</div>
<div class="flex flex-col min-w-0">
<span
class="text-[10px] font-bold uppercase text-gray-400 tracking-widest leading-none mb-1">Dosen
Pengampu</span>
<span @class([
'truncate font-medium',
'text-gray-900 dark:text-gray-200' => !$course->is_ongoing,
'text-primary-900 dark:text-primary-50' => $course->is_ongoing,
])>
{{ $course->lecturer_name }}
</span>
</div>
</div>
</div>
</div>
<div @class([
'flex items-center justify-end gap-2 p-4 pt-0 rounded-b-xl',
'bg-primary-100/30 dark:bg-primary-900/10 pt-4' => $course->is_ongoing,
])>
<span
class="text-[10px] font-bold text-primary-600 dark:text-primary-400 uppercase tracking-tight group-hover:underline">
Buka &rarr;
</span>
</div>
</div>
</a>
@endforeach
</div>
</x-filament::section>
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,103 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex items-center justify-between gap-4 mb-6">
<div class="w-full max-w-xl">
{{ $this->form }}
</div>
</div>
@if($this->getCourses()->isNotEmpty())
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
@foreach($this->getCourses() as $course)
<div
x-data="{ open: false }"
class="fi-card rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm overflow-hidden transition hover:shadow-md h-fit"
>
<!-- Course Header Card -->
<div
@click="open = !open"
class="p-5 cursor-pointer space-y-4 group"
>
<div class="flex items-center justify-between">
<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">
{{ $course->code }}
</span>
<div class="flex items-center gap-2 text-gray-400 dark:text-gray-500">
<span class="text-xs font-medium">
{{ $course->classSessions->count() }} Sesi
</span>
<x-heroicon-m-chevron-down
class="w-4 h-4 transition-transform duration-300"
x-bind:class="open ? 'rotate-180' : ''"
/>
</div>
</div>
<div class="space-y-1">
<h3 class="text-base font-bold text-gray-950 dark:text-white leading-tight group-hover:text-primary-600 dark:group-hover:text-primary-400 transition">
{{ $course->name }}
</h3>
<div class="flex items-center text-sm text-gray-500 dark:text-gray-400">
<x-heroicon-m-user class="w-4 h-4 mr-1.5 opacity-70" />
<span class="truncate">{{ $course->lecturer->full_name ?? 'Dosen Belum Ditentukan' }}</span>
</div>
</div>
</div>
<!-- Sessions List (Collapsible) -->
<div
x-show="open"
x-collapse
x-cloak
class="border-t border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/20"
>
@if($course->classSessions->isNotEmpty())
<div class="divide-y divide-gray-100 dark:divide-gray-800 max-h-80 overflow-y-auto">
@foreach($course->classSessions as $session)
<div class="p-4 flex items-center justify-between hover:bg-gray-100/50 dark:hover:bg-gray-800/50 transition">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-[10px] font-bold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
#{{ $session->session_number }}
</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $session->date->format('l, d/m/Y') }}
</span>
</div>
<p class="text-[12px] text-gray-600 dark:text-gray-400 line-clamp-1">
{{ $session->title ?? 'Materi belum diisi' }}
</p>
<div class="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
<x-heroicon-o-clock class="w-3.5 h-3.5 text-primary-500" />
{{ $session->start_time->format('H:i') }} - {{ $session->end_time->format('H:i') }}
</div>
</div>
<div class="flex items-center gap-1">
{{ ($this->editSessionAction)(['session' => $session->id]) }}
{{ ($this->deleteSessionAction)(['session' => $session->id]) }}
</div>
</div>
@endforeach
</div>
@else
<div class="p-6 text-center">
<x-heroicon-o-chat-bubble-bottom-center-text class="w-8 h-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<p class="text-xs text-gray-500 dark:text-gray-400 italic">
Belum ada laporan sesi untuk mata kuliah ini.
</p>
</div>
@endif
</div>
</div>
@endforeach
</div>
@else
<x-filament::empty-state
icon="heroicon-o-presentation-chart-bar"
heading="Tidak ada mata kuliah"
description="Mata kuliah untuk semester ini belum tersedia atau tidak sesuai dengan pencarian."
/>
@endif
</x-filament::section>
</x-filament-panels::page>

View File

@ -0,0 +1,58 @@
<x-filament-panels::page>
<div class="flex items-center gap-4 mb-6">
<a href="{{ \App\Filament\Resources\Learning\ClassSessions\ClassSessionResource::getUrl('index') }}"
class="fi-btn fi-btn-size-md relative grid-flow-col items-center justify-center font-semibold outline-none transition duration-75 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-70 fi-btn-color-gray fi-color-gray bg-white dark:bg-white/5 text-gray-950 dark:text-white shadow-sm ring-1 ring-gray-950/10 dark:ring-white/20 hover:bg-gray-50 dark:hover:bg-white/10 fi-btn-icon-start gap-1 p-2 rounded-lg">
<x-heroicon-m-arrow-left class="w-5 h-5 text-gray-500" />
</a>
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight text-gray-950 dark:text-white sm:text-3xl">
{{ $this->getTitle() }}
</h1>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
Data sesi pembelajaran untuk mata kuliah {{ $this->getCourse()->name }}
</p>
</div>
</div>
<x-filament::section>
@if ($this->getSessions()->isNotEmpty())
<div class="space-y-4">
@foreach ($this->getSessions() as $session)
<div
class="fi-card p-5 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm flex items-center justify-between hover:shadow-md transition">
<div class="flex items-start gap-4">
<div
class="flex flex-col items-center justify-center w-12 h-12 rounded-lg bg-primary-100 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 font-bold border border-primary-200 dark:border-primary-500/20">
<span class="text-[10px] uppercase leading-none opacity-60 mb-0.5 mt-1">Sesi</span>
<span class="text-xl leading-none mb-1">#{{ $session->session_number }}</span>
</div>
<div class="space-y-1">
<div
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-1.5 font-bold text-gray-900 dark:text-white">
<x-heroicon-o-calendar class="w-4 h-4 text-primary-500" />
{{ $session->date->format('l, d F Y') }}
</div>
<div class="flex items-center gap-1.5 font-medium">
<x-heroicon-o-clock class="w-4 h-4 text-primary-500" />
{{ $session->start_time->format('H:i') }} -
{{ $session->end_time->format('H:i') }}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-1">
{{ ($this->editSessionAction)(['session' => $session->id]) }}
{{ ($this->deleteSessionAction)(['session' => $session->id]) }}
</div>
</div>
@endforeach
</div>
@else
<x-filament::empty-state icon="heroicon-o-presentation-chart-bar" 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>

View File

@ -3,7 +3,7 @@
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
return redirect()->route('filament.admin.auth.login');
});
Route::get('/share/presensi/{token}', [\App\Http\Controllers\ShareAttendanceController::class, 'show'])->name('share.attendance');