feat: Implement attendance tracking functionality with new model, migration, relationships, and Filament UI.
This commit is contained in:
parent
9207b1aaf4
commit
6d1ce9cdf6
@ -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('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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
28
app/Models/Attendance.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user