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);
|
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\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class CourseSchedule extends Model
|
class CourseSchedule extends Model
|
||||||
@ -25,4 +26,9 @@ public function course(): BelongsTo
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Course::class);
|
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\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class Student extends Model
|
class Student extends Model
|
||||||
@ -40,4 +41,9 @@ public function user(): BelongsTo
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
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