feat: Implement attendance tracking functionality with new model, migration, relationships, and Filament UI.

This commit is contained in:
Yoga Pangestu 2026-03-11 21:59:57 +07:00
parent 9207b1aaf4
commit 6d1ce9cdf6
8 changed files with 401 additions and 0 deletions

View File

@ -0,0 +1,34 @@
<?php
namespace App\Filament\Resources\Learning\Attendances;
use App\Filament\Resources\Learning\Attendances\Pages\AttendancePage;
use App\Models\Attendance;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Support\Icons\Heroicon;
use UnitEnum;
class AttendanceResource extends Resource
{
protected static ?string $model = Attendance::class;
protected static string|UnitEnum|null $navigationGroup = 'Pembelajaran';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCheckCircle;
protected static ?string $navigationLabel = 'Presensi';
protected static ?int $navigationSort = 20;
protected static ?string $modelLabel = 'Presensi';
protected static ?string $pluralModelLabel = 'Presensi';
public static function getPages(): array
{
return [
'index' => AttendancePage::route('/'),
];
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Filament\Resources\Learning\Attendances\Pages;
use App\Filament\Resources\Learning\Attendances\AttendanceResource;
use App\Filament\Support\SystemNotification;
use App\Models\Attendance;
use App\Models\CourseSchedule;
use App\Models\User;
use Filament\Resources\Pages\Page;
class AttendancePage extends Page
{
protected static string $resource = AttendanceResource::class;
protected static ?string $title = 'Presensi Kuliah';
protected static ?string $navigationLabel = 'Presensi';
protected string $view = 'filament.resources.learning.attendances.pages.list-attendances';
public function getSchedules()
{
$student = auth()->user()->student;
$today = now()->dayOfWeek;
return CourseSchedule::query()
->where('day_of_week', $today)
->with(['course', 'attendances' => function ($q) use ($student) {
$q->where('student_id', $student->id)
->whereDate('date', now()->toDateString());
}])
->orderBy('start_time')
->get();
}
public function getScheduleCards()
{
return $this->getSchedules()
->map(function ($schedule) {
$attendance = $schedule->attendances->first();
$isAttended = $attendance !== null;
$now = now();
$startTime = now()->setTimeFrom($schedule->start_time);
$canAttend = $now->greaterThanOrEqualTo($startTime);
$statusLabel = 'Belum Presensi';
$statusColor = 'warning';
$statusIcon = 'heroicon-o-clock';
if ($isAttended) {
$statusLabel = 'Hadir';
$statusColor = 'success';
$statusIcon = 'heroicon-o-check-circle';
} elseif (! $canAttend) {
$statusLabel = 'Belum Dimulai';
$statusColor = 'gray';
$statusIcon = 'heroicon-o-lock-closed';
}
return (object) [
'id' => $schedule->id,
'course_name' => $schedule->course->name,
'time_range' => $schedule->start_time->format('H:i').' - '.$schedule->end_time->format('H:i'),
'is_attended' => $isAttended,
'can_attend' => $canAttend && ! $isAttended,
'status_label' => $statusLabel,
'status_color' => $statusColor,
'status_icon' => $statusIcon,
'attended_at' => $isAttended ? $attendance->attended_at->format('H:i') : null,
];
});
}
protected function getViewData(): array
{
return [
'scheduleCards' => $this->getScheduleCards(),
];
}
public function attend(int $scheduleId): void
{
/** @var User $user */
$user = auth()->user();
$student = $user->student;
if (! $student) {
return;
}
$schedule = CourseSchedule::find($scheduleId);
if (! $schedule) {
return;
}
// Check if already attended
$existing = Attendance::where('student_id', $student->id)
->where('course_schedule_id', $scheduleId)
->whereDate('date', now()->toDateString())
->first();
if ($existing) {
SystemNotification::warning(
'Presensi Sudah Tercatat ⚠️',
'Anda telah melakukan presensi untuk jadwal perkuliahan ini pada hari ini.'
)->send();
return;
}
// Check time
$startTime = now()->setTimeFrom($schedule->start_time);
if (now()->lessThan($startTime)) {
SystemNotification::warning(
'Presensi Belum Dibuka ⏳',
'Waktu presensi untuk jadwal perkuliahan ini belum dimulai. Silakan lakukan presensi setelah waktu dimulai.'
)->send();
return;
}
Attendance::create([
'student_id' => $student->id,
'course_schedule_id' => $scheduleId,
'date' => now()->toDateString(),
'attended_at' => now(),
]);
SystemNotification::success(
'Presensi Berhasil Dicatat ✅',
'Kehadiran Anda telah berhasil direkam ke dalam sistem.'
)->send();
}
}

28
app/Models/Attendance.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Attendance extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return [
'attended_at' => 'datetime',
'date' => 'date',
];
}
public function student()
{
return $this->belongsTo(Student::class);
}
public function courseSchedule()
{
return $this->belongsTo(CourseSchedule::class);
}
}

View File

@ -31,4 +31,9 @@ public function materials(): HasMany
{
return $this->hasMany(Material::class);
}
public function studyGroups(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(StudyGroup::class, 'study_group_courses');
}
}

View File

@ -5,6 +5,7 @@
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 CourseSchedule extends Model
@ -25,4 +26,9 @@ public function course(): BelongsTo
{
return $this->belongsTo(Course::class);
}
public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
}
}

View File

@ -8,6 +8,7 @@
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 Student extends Model
@ -40,4 +41,9 @@ public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('attendances', function (Blueprint $table) {
$table->id();
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
$table->foreignId('course_schedule_id')->constrained()->cascadeOnDelete();
$table->date('date');
$table->timestamp('attended_at')->nullable();
$table->timestamps();
$table->unique(['student_id', 'course_schedule_id', 'date']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attendances');
}
};

View File

@ -0,0 +1,152 @@
<x-filament-panels::page>
<x-filament::section>
<x-slot name="heading">Sesi Kuliah</x-slot>
<x-slot name="description">Silakan melakukan presensi sesuai dengan waktu kelas.</x-slot>
@if ($scheduleCards->isNotEmpty())
<div class="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6 pb-8">
@foreach ($scheduleCards as $card)
<div class="break-inside-avoid mb-6">
<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' => !$card->is_attended,
'bg-primary-50/30 dark:bg-primary-900/10 border-primary-500 shadow-md ring-1 ring-primary-500' =>
$card->is_attended,
])>
@if ($card->is_attended)
<div class="absolute -top-3 -right-3 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 dark:text-gray-900 shadow-lg ring-2 ring-white dark:ring-gray-900 uppercase tracking-widest">
<x-heroicon-m-check-badge class="w-3.5 h-3.5" />
Hadir
</span>
</div>
@endif
<div class="p-6 space-y-4 flex-1">
<div class="flex flex-wrap items-center gap-1.5">
<span @class([
'inline-flex items-center rounded-md px-2.5 py-1 text-[10px] font-bold ring-1 ring-inset uppercase tracking-tight',
'bg-primary-50 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 ring-primary-600/20' => !$card->is_attended,
'bg-white dark:bg-primary-800/40 text-primary-700 dark:text-primary-200 ring-primary-500/30' =>
$card->is_attended,
])>
{{ $card->course_name }}
</span>
</div>
<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' => !$card->is_attended,
'text-primary-900 dark:text-primary-100' => $card->is_attended,
])>
<x-heroicon-o-finger-print @class([
'w-5 h-5 shrink-0',
'opacity-40' => !$card->is_attended,
'text-primary-500' => $card->is_attended,
]) />
Presensi Kuliah
</h3>
</div>
<div class="space-y-4">
<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' => !$card->is_attended,
'bg-white dark:bg-primary-800 border-primary-300 dark:border-primary-600' =>
$card->is_attended,
])>
<x-heroicon-m-clock @class([
'w-4 h-4',
'text-primary-600 dark:text-primary-300' => !$card->is_attended,
'text-primary-700 dark:text-primary-200' => $card->is_attended,
]) />
</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">Waktu
Kuliah</span>
<span @class([
'truncate font-medium',
'text-gray-900 dark:text-gray-200' => !$card->is_attended,
'text-primary-900 dark:text-primary-50' => $card->is_attended,
])>
{{ $card->time_range }}
</span>
</div>
</div>
<div @class([
'pt-4 border-t',
'border-gray-100 dark:border-gray-800' => !$card->is_attended,
'border-primary-200 dark:border-primary-800' => $card->is_attended,
])>
<div class="flex items-center justify-between mb-3">
<span
class="text-[10px] font-bold text-gray-400 uppercase tracking-widest leading-none">Status</span>
<span @class([
'text-[10px] font-bold px-1.5 py-0.5 rounded ring-1 ring-inset',
'bg-success-50 dark:bg-success-500/10 text-success-600 dark:text-success-400 ring-success-600/20' =>
$card->is_attended,
'bg-warning-50 dark:bg-warning-500/10 text-warning-600 dark:text-warning-400 ring-warning-600/20' =>
!$card->is_attended && $card->can_attend,
'bg-gray-50 dark:bg-gray-500/10 text-gray-600 dark:text-gray-400 ring-gray-600/20' =>
!$card->is_attended && !$card->can_attend,
])>
{{ $card->status_label }}
</span>
</div>
@if ($card->is_attended)
<span
class="text-[10px] font-bold uppercase text-gray-400 tracking-widest leading-none">
Tercatat: <span
class="text-primary-600 dark:text-primary-200">{{ $card->attended_at }}</span>
</span>
@endif
</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' =>
$card->is_attended || $card->can_attend,
])>
@if ($card->can_attend)
<x-filament::button wire:click="attend({{ $card->id }})" color="primary"
size="xs"
class="rounded-lg shadow-sm font-bold uppercase tracking-tight text-[10px]">
Absen Sekarang
</x-filament::button>
@elseif ($card->is_attended)
<div
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white dark:bg-primary-800 border border-primary-300 dark:border-primary-600 shadow-sm">
<x-heroicon-m-hand-thumb-up
class="w-3.5 h-3.5 text-primary-600 dark:text-primary-200" />
<span
class="text-[10px] font-bold text-primary-700 dark:text-primary-200 uppercase tracking-tight">
Selesai
</span>
</div>
@else
<div class="px-2 py-1">
<span
class="text-[10px] font-bold text-gray-400 uppercase tracking-widest leading-none italic">Belum
Dibuka</span>
</div>
@endif
</div>
</div>
</div>
@endforeach
</div>
@else
<x-filament::empty-state icon="heroicon-o-finger-print" heading="Tidak ada data yang ditemukan"
description="Setelah jadwal Anda muncul, Anda dapat melakukan presensi di sini." iconColor="gray">
</x-filament::empty-state>
@endif
</x-filament::section>
</x-filament-panels::page>