feat: implement missed attendance handling and split schedule cards into today and missed sections

This commit is contained in:
Yoga Pangestu 2026-04-07 11:44:29 +07:00
parent 7f29146452
commit 75c71a1ac9
3 changed files with 178 additions and 42 deletions

View File

@ -60,6 +60,18 @@ public function historyDescription(): string
return SystemNotification::getByKey('labels.attendance_history.description'); return SystemNotification::getByKey('labels.attendance_history.description');
} }
#[Computed]
public function missedHeading(): string
{
return SystemNotification::getByKey('labels.missed_attendance.title');
}
#[Computed]
public function missedDescription(): string
{
return SystemNotification::getByKey('labels.missed_attendance.description');
}
#[Computed] #[Computed]
public function emptyHeading(): string public function emptyHeading(): string
{ {
@ -110,7 +122,8 @@ public function getSchedules()
->with(['course', 'attendances' => function ($q) use ($student) { ->with(['course', 'attendances' => function ($q) use ($student) {
$q->where('student_id', $student->id); $q->where('student_id', $student->id);
}]) }])
->orderBy('start_time') ->orderBy('date', 'desc')
->orderBy('start_time', 'asc')
->get(); ->get();
} }
@ -123,9 +136,14 @@ public function scheduleCards()
$isAttended = $attendance !== null; $isAttended = $attendance !== null;
$isSent = $schedule->is_sent_to_lecturer; $isSent = $schedule->is_sent_to_lecturer;
$sessionStart = $schedule->date->copy()->setTimeFrom($schedule->start_time);
$now = now(); $now = now();
$startTime = now()->setTimeFrom($schedule->start_time); $isPassed = $now->greaterThanOrEqualTo($sessionStart);
$canAttend = $now->greaterThanOrEqualTo($startTime) && ! $isSent; $isMissed = $schedule->date->isPast() && ! $schedule->date->isToday();
// If it's a missed session, we don't care about the time match anymore,
// as long as it's not sent yet.
$canAttend = ! $isSent && ($isMissed || $isPassed);
$statusLabel = 'Belum Presensi'; $statusLabel = 'Belum Presensi';
$statusColor = 'warning'; $statusColor = 'warning';
@ -139,19 +157,24 @@ public function scheduleCards()
$statusLabel = 'Terkirim'; $statusLabel = 'Terkirim';
$statusColor = 'danger'; $statusColor = 'danger';
$statusIcon = 'heroicon-o-paper-airplane'; $statusIcon = 'heroicon-o-paper-airplane';
} elseif (! $canAttend) { } elseif (! $isPassed && ! $isMissed) {
$statusLabel = 'Belum Dimulai'; $statusLabel = 'Belum Dimulai';
$statusColor = 'gray'; $statusColor = 'gray';
$statusIcon = 'heroicon-o-lock-closed'; $statusIcon = 'heroicon-o-lock-closed';
} }
$isMissed = $schedule->date->isPast() && ! $schedule->date->isToday();
return (object) [ return (object) [
'id' => $schedule->id, 'id' => $schedule->id,
'course_name' => $schedule->course?->name, 'course_name' => $schedule->course?->name,
'lecturer_name' => $schedule->course?->lecturer ?? 'Belum Ditentukan', 'lecturer_name' => $schedule->course?->lecturer ?? 'Belum Ditentukan',
'session_number' => $schedule->session_number,
'date' => $schedule->date?->translatedFormat('l, d F Y'),
'time_range' => $schedule->start_time?->format('H:i').' - '.$schedule->end_time?->format('H:i'), 'time_range' => $schedule->start_time?->format('H:i').' - '.$schedule->end_time?->format('H:i'),
'is_attended' => $isAttended, 'is_attended' => $isAttended,
'is_sent_to_lecturer' => $isSent, 'is_sent_to_lecturer' => $isSent,
'is_missed' => $isMissed,
'can_attend' => $canAttend && ! $isAttended, 'can_attend' => $canAttend && ! $isAttended,
'status_label' => $statusLabel, 'status_label' => $statusLabel,
'status_color' => $statusColor, 'status_color' => $statusColor,
@ -197,6 +220,18 @@ public function scheduleCards()
}); });
} }
#[Computed]
public function todayScheduleCards()
{
return $this->scheduleCards()->filter(fn ($card) => ! $card->is_missed);
}
#[Computed]
public function missedScheduleCards()
{
return $this->scheduleCards()->filter(fn ($card) => $card->is_missed);
}
public function attend(int $sessionId): void public function attend(int $sessionId): void
{ {
/** @var User|null $user */ /** @var User|null $user */
@ -223,23 +258,30 @@ public function attend(int $sessionId): void
return; return;
} }
// Check session status $isSent = $session->is_sent_to_lecturer;
if ($session->is_sent_to_lecturer) { $isMissed = $session->date->isPast() && ! $session->date->isToday();
$now = now();
$startTime = $session->date->copy()->setTimeFrom($session->start_time);
// Attendance is allowed if:
// 1. Not sent yet
// 2. Either it's a missed session (from previous days) OR it's today and already started
if (! $isSent) {
if ($isMissed || $now->greaterThanOrEqualTo($startTime)) {
// Proceed with attendance
} else {
SystemNotification::send('attendance_not_started', type: 'warning')
->send();
return;
}
} else {
SystemNotification::send('attendance_closed', type: 'danger') SystemNotification::send('attendance_closed', type: 'danger')
->send(); ->send();
return; return;
} }
// Check time
$startTime = $session->start_time;
if (now()->lessThan($startTime)) {
SystemNotification::send('attendance_not_started', type: 'warning')
->send();
return;
}
// Find schedule for compatibility // Find schedule for compatibility
$schedule = CourseSchedule::where('course_id', $session->course_id) $schedule = CourseSchedule::where('course_id', $session->course_id)
->where('day_of_week', $session->date?->dayOfWeekIso) ->where('day_of_week', $session->date?->dayOfWeekIso)

View File

@ -219,14 +219,18 @@
], ],
'attendance_section' => [ 'attendance_section' => [
'title' => 'Ayo Presensi Dulu! 🙋‍♂️✨', 'title' => 'Ayo Presensi Dulu! 🙋‍♂️✨',
'description' => 'Jangan lupa absen ya kalau kelasnya udah mulai! Biar tercatat hadir. 💪', 'description' => 'Jangan lupa presensi ya kalau kelasnya udah mulai! Biar tercatat hadir. 💪',
],
'missed_attendance' => [
'title' => 'Presensi Terlewat! ⌛🏃‍♂️',
'description' => 'Ayo segera presensi buat kelas yang udah lewat ini sebelum sesi ditutup! Jangan sampai alpa ya. 💪✨',
], ],
'attendance_history' => [ 'attendance_history' => [
'title' => 'Jejak Presensimu 🕰️📜', 'title' => 'Jejak Presensimu 🕰️📜',
'description' => 'Di sini kamu bisa ngecek semua histori kehadiranmu sebelumnya. Rajin-rajin ya! 🎓', 'description' => 'Di sini kamu bisa ngecek semua histori kehadiranmu sebelumnya. Rajin-rajin ya! 🎓',
], ],
'empty_attendance' => [ 'empty_attendance' => [
'title' => 'Yah, Absensi Belum Muncul! 💁‍♂️🌀', 'title' => 'Yah, Presensi Belum Muncul! 💁‍♂️🌀',
'description' => 'Sabar ya, setelah jadwal perkuliahan kamu muncul baru bisa diabsen di sini. Pantau terus jam kuliahnya! 🕒💪', 'description' => 'Sabar ya, setelah jadwal perkuliahan kamu muncul baru bisa diabsen di sini. Pantau terus jam kuliahnya! 🕒💪',
], ],
'assignment_list' => [ 'assignment_list' => [
@ -537,6 +541,10 @@
'title' => 'Sesi Kuliah', 'title' => 'Sesi Kuliah',
'description' => 'Silakan melakukan presensi sesuai dengan waktu kelas.', 'description' => 'Silakan melakukan presensi sesuai dengan waktu kelas.',
], ],
'missed_attendance' => [
'title' => 'Presensi Terlewat',
'description' => 'Segera lakukan presensi untuk sesi perkuliahan yang telah berlalu berikut sebelum akses ditutup.',
],
'attendance_history' => [ 'attendance_history' => [
'title' => 'Riwayat Presensi', 'title' => 'Riwayat Presensi',
'description' => 'Berikut adalah riwayat kehadiran Anda pada sesi perkuliahan.', 'description' => 'Berikut adalah riwayat kehadiran Anda pada sesi perkuliahan.',

View File

@ -1,27 +1,30 @@
<x-filament-panels::page> <x-filament-panels::page>
<x-filament::section <x-filament::section icon="{{ $this->sectionIcon }}" icon-color="primary">
icon="{{ $this->sectionIcon }}"
icon-color="primary"
>
<x-slot name="heading">{{ $this->heading }}</x-slot> <x-slot name="heading">{{ $this->heading }}</x-slot>
<x-slot name="description">{{ $this->description }}</x-slot> <x-slot name="description">{{ $this->description }}</x-slot>
@if (count($this->scheduleCards)) @if (count($this->todayScheduleCards))
<div class="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6"> <div class="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6">
@foreach ($this->scheduleCards as $card) @foreach ($this->todayScheduleCards as $card)
<div class="break-inside-avoid mb-6"> <div class="break-inside-avoid mb-6">
<div class="{{ $card->card_classes }}"> <div class="{{ $card->card_classes }}">
<div class="p-6 space-y-4 flex-1"> <div class="p-6 space-y-4 flex-1">
<div class="space-y-1"> <div class="space-y-1">
<h3 class="{{ $card->title_classes }}"> <div class="flex items-center justify-between gap-2">
<x-heroicon-o-book-open @class([ <h3 class="{{ $card->title_classes }}">
'w-5 h-5 shrink-0', <x-heroicon-o-book-open @class([
'opacity-40' => !$card->is_attended, 'w-5 h-5 shrink-0',
'text-primary-500' => $card->is_attended, 'opacity-40' => !$card->is_attended,
]) /> 'text-primary-500' => $card->is_attended,
{{ $card->course_name }} ]) />
</h3> {{ $card->course_name }}
</h3>
<span
class="text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 ring-1 ring-inset ring-gray-600/10 shrink-0">
Sesi {{ $card->session_number }}
</span>
</div>
<div class="{{ $card->lecturer_wrapper_classes }}"> <div class="{{ $card->lecturer_wrapper_classes }}">
<x-heroicon-m-user @class(['w-3.5 h-3.5 shrink-0', 'opacity-50' => !$card->is_attended]) /> <x-heroicon-m-user @class(['w-3.5 h-3.5 shrink-0', 'opacity-50' => !$card->is_attended]) />
<span class="text-[11px] font-medium leading-none"> <span class="text-[11px] font-medium leading-none">
@ -75,8 +78,8 @@ class="text-primary-600 dark:text-primary-200">{{ $card->attended_at }}</span>
<div class="{{ $card->footer_classes }}"> <div class="{{ $card->footer_classes }}">
@if ($card->can_attend) @if ($card->can_attend)
<x-filament::button wire:click="attend({{ $card->id }})" color="primary" <x-filament::button wire:click="attend({{ $card->id }})" color="primary"
size="xs" size="xs" icon="heroicon-m-finger-print"
class="rounded-lg shadow-sm font-bold uppercase tracking-tight text-[10px]"> class="rounded-lg shadow-sm font-bold uppercase tracking-tight text-[10px] w-full">
Presensi Sekarang Presensi Sekarang
</x-filament::button> </x-filament::button>
@elseif ($card->is_attended) @elseif ($card->is_attended)
@ -109,19 +112,102 @@ class="text-[10px] font-bold text-gray-400 uppercase tracking-widest leading-non
@endforeach @endforeach
</div> </div>
@else @else
<x-filament::empty-state icon="heroicon-o-finger-print" <x-filament::empty-state icon="heroicon-o-finger-print" heading="{{ $this->emptyHeading }}"
heading="{{ $this->emptyHeading }}" description="{{ $this->emptyDescription }}" iconColor="gray">
description="{{ $this->emptyDescription }}"
iconColor="gray">
</x-filament::empty-state> </x-filament::empty-state>
@endif @endif
</x-filament::section> </x-filament::section>
<x-filament::section @if (count($this->missedScheduleCards))
icon="{{ $this->historyIcon }}" <x-filament::section icon="heroicon-o-exclamation-triangle" icon-color="danger"
icon-color="gray" class="mb-6 !bg-danger-50/5 border-danger-100 dark:border-danger-900/50">
> <x-slot name="heading">{{ $this->missedHeading }}</x-slot>
<x-slot name="description">{{ $this->missedDescription }}</x-slot>
<div class="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6 mt-4">
@foreach ($this->missedScheduleCards as $card)
<div class="break-inside-avoid mb-6">
<div class="{{ $card->card_classes }}">
<div class="p-6 space-y-4 flex-1">
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<h3 class="{{ $card->title_classes }}">
<x-heroicon-o-book-open @class([
'w-5 h-5 shrink-0',
'opacity-40' => !$card->is_attended,
'text-primary-500' => $card->is_attended,
]) />
{{ $card->course_name }}
</h3>
<span
class="text-[10px] font-bold px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 ring-1 ring-inset ring-gray-600/10 shrink-0">
Sesi {{ $card->session_number }}
</span>
</div>
<div class="{{ $card->lecturer_wrapper_classes }}">
<x-heroicon-m-user @class(['w-3.5 h-3.5 shrink-0', 'opacity-50' => !$card->is_attended]) />
<span class="text-[11px] font-medium leading-none">
{{ $card->lecturer_name }}
</span>
</div>
</div>
<div class="space-y-4">
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400 pt-4">
<div class="{{ $card->icon_wrapper_classes }}">
<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">
{{ $card->date }}
</span>
<span class="{{ $card->time_label_classes }}">
{{ $card->time_range }}
</span>
</div>
</div>
<div @class([
'pt-4 border-t',
'border-gray-100 dark:border-gray-700' => !$card->is_attended,
'border-primary-200 dark:border-primary-800' => $card->is_attended,
])>
<div class="flex items-center justify-between">
<span
class="text-[10px] font-bold text-gray-400 uppercase tracking-widest leading-none">Status</span>
<div class="flex items-center gap-2">
<span class="{{ $card->status_badge_classes }}">
{{ $card->status_label }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="{{ $card->footer_classes }}">
@if ($card->can_attend)
<x-filament::button wire:click="attend({{ $card->id }})" color="primary"
size="xs" icon="heroicon-m-finger-print"
class="rounded-lg shadow-sm font-bold uppercase tracking-tight text-[10px] w-full">
Presensi Sekarang
</x-filament::button>
@endif
</div>
</div>
</div>
@endforeach
</div>
</x-filament::section>
@endif
<x-filament::section icon="{{ $this->historyIcon }}" icon-color="gray">
<x-slot name="heading">{{ $this->historyHeading }}</x-slot> <x-slot name="heading">{{ $this->historyHeading }}</x-slot>
<x-slot name="description">{{ $this->historyDescription }}</x-slot> <x-slot name="description">{{ $this->historyDescription }}</x-slot>