From d0ebaf84336a36e0521cc72ca3234094b239dc89 Mon Sep 17 00:00:00 2001 From: Saufi Date: Sat, 16 May 2026 20:20:27 +0800 Subject: [PATCH] feat: public check-in flow and attendance (Fasa 5) - AttendanceService: staffCheckin and walkInRegister methods - CheckinController: QR-based check-in (staff & walk-in external) - AttendanceCheckController: semak kehadiran & sijil status - Views: checkin show/success/already/unavailable, semak show/result Co-Authored-By: Claude Sonnet 4.6 --- .../Public/AttendanceCheckController.php | 51 +++++- .../Controllers/Public/CheckinController.php | 111 ++++++++++++- app/Services/AttendanceService.php | 123 ++++++++++++++ .../views/public/checkin/already.blade.php | 31 ++++ resources/views/public/checkin/show.blade.php | 152 ++++++++++++++++++ .../views/public/checkin/success.blade.php | 50 ++++++ .../public/checkin/unavailable.blade.php | 23 +++ resources/views/public/semak/result.blade.php | 90 +++++++++++ resources/views/public/semak/show.blade.php | 45 ++++++ 9 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 app/Services/AttendanceService.php create mode 100644 resources/views/public/checkin/already.blade.php create mode 100644 resources/views/public/checkin/show.blade.php create mode 100644 resources/views/public/checkin/success.blade.php create mode 100644 resources/views/public/checkin/unavailable.blade.php create mode 100644 resources/views/public/semak/result.blade.php create mode 100644 resources/views/public/semak/show.blade.php diff --git a/app/Http/Controllers/Public/AttendanceCheckController.php b/app/Http/Controllers/Public/AttendanceCheckController.php index d5f61d3..c8c3666 100644 --- a/app/Http/Controllers/Public/AttendanceCheckController.php +++ b/app/Http/Controllers/Public/AttendanceCheckController.php @@ -3,9 +3,58 @@ namespace App\Http\Controllers\Public; use App\Http\Controllers\Controller; +use App\Models\Certificate; +use App\Models\Participant; +use App\Models\ProgramQrCode; use Illuminate\Http\Request; +use Illuminate\View\View; class AttendanceCheckController extends Controller { - // + public function show(string $qr_token): View + { + $qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail(); + $program = $qrCode->program; + + abort_if($program->status !== 'published', 404); + + return view('public.semak.show', compact('program', 'qrCode')); + } + + public function check(string $qr_token, Request $request): View + { + $qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail(); + $program = $qrCode->program; + + $request->validate([ + 'no_kp' => ['required', 'digits:12'], + ], [ + 'no_kp.required' => 'Sila masukkan No. Kad Pengenalan anda.', + 'no_kp.digits' => 'No. Kad Pengenalan mestilah 12 digit tanpa sempang.', + ]); + + $noKp = preg_replace('/[^0-9]/', '', $request->no_kp); + $participant = Participant::where('no_kp', $noKp)->first(); + + if (! $participant) { + return view('public.semak.result', [ + 'program' => $program, + 'qrCode' => $qrCode, + 'found' => false, + 'participant' => null, + 'attendance' => null, + 'certificate' => null, + ]); + } + + $attendance = $participant->attendanceForProgram($program->id); + $certificate = $attendance + ? Certificate::where('program_id', $program->id) + ->where('participant_id', $participant->id) + ->first() + : null; + + return view('public.semak.result', compact('program', 'qrCode', 'participant', 'attendance', 'certificate')) + ->with('found', (bool) $attendance); + } } diff --git a/app/Http/Controllers/Public/CheckinController.php b/app/Http/Controllers/Public/CheckinController.php index 6fa3966..ca83435 100644 --- a/app/Http/Controllers/Public/CheckinController.php +++ b/app/Http/Controllers/Public/CheckinController.php @@ -3,9 +3,118 @@ namespace App\Http\Controllers\Public; use App\Http\Controllers\Controller; +use App\Models\ProgramQrCode; +use App\Services\AttendanceService; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\View\View; class CheckinController extends Controller { - // + public function __construct(private AttendanceService $attendanceService) {} + + public function show(string $qr_token): View|RedirectResponse + { + $qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail(); + $program = $qrCode->program; + + if ($program->status !== 'published') { + return view('public.checkin.unavailable', compact('program', 'qrCode')) + ->with('message', 'Program ini belum dibuka atau sudah ditutup.'); + } + + // If download period is active → redirect to attendance check page + if ($program->isDownloadOpen()) { + return redirect()->route('public.semak.show', $qr_token); + } + + // Check-in not yet open + if ($program->checkin_start_at && now()->lt($program->checkin_start_at)) { + return view('public.checkin.unavailable', compact('program', 'qrCode')) + ->with('message', 'Check-in belum dibuka. Mula pada ' . $program->checkin_start_at->format('d M Y, H:i')); + } + + // Check-in already closed + if ($program->checkin_end_at && now()->gt($program->checkin_end_at)) { + return view('public.checkin.unavailable', compact('program', 'qrCode')) + ->with('message', 'Tempoh check-in telah tamat.'); + } + + return view('public.checkin.show', compact('program', 'qrCode')); + } + + public function staffCheckin(string $qr_token, Request $request): View|RedirectResponse + { + $qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail(); + $program = $qrCode->program; + + $request->validate([ + 'no_kp' => ['required', 'string', 'max:20'], + ], [ + 'no_kp.required' => 'Sila masukkan No. Kad Pengenalan anda.', + ]); + + $result = $this->attendanceService->staffCheckin($program, $request->no_kp, $request); + + return match ($result['status']) { + 'success' => view('public.checkin.success', [ + 'program' => $program, + 'participant' => $result['participant'], + 'attendance' => $result['attendance'], + 'qrCode' => $qrCode, + ]), + 'already_checked_in' => view('public.checkin.already', [ + 'program' => $program, + 'participant' => $result['participant'], + 'attendance' => $result['attendance'], + ]), + 'not_found' => back()->withInput() + ->with('error', 'No. Kad Pengenalan tidak dijumpai dalam senarai peserta program ini.') + ->with('show_external_option', $program->allow_walk_in), + 'not_registered' => back()->withInput() + ->with('error', 'No. Kad Pengenalan tidak dijumpai dalam senarai pra-daftar.') + ->with('show_external_option', $program->allow_walk_in), + default => back()->with('error', 'Ralat tidak dijangka. Sila cuba lagi.'), + }; + } + + public function externalRegister(string $qr_token, Request $request): View|RedirectResponse + { + $qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail(); + $program = $qrCode->program; + + if (! $program->allow_walk_in) { + return back()->with('error', 'Pendaftaran orang luar tidak dibenarkan untuk program ini.'); + } + + $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'no_kp' => ['required', 'digits:12'], + 'email' => ['nullable', 'email', 'max:255'], + 'phone' => ['nullable', 'string', 'max:20'], + 'agency' => ['nullable', 'string', 'max:255'], + ], [ + 'name.required' => 'Sila masukkan nama penuh anda.', + 'no_kp.required' => 'Sila masukkan No. Kad Pengenalan anda.', + 'no_kp.digits' => 'No. Kad Pengenalan mestilah 12 digit tanpa sempang.', + 'email.email' => 'Format emel tidak sah.', + ]); + + $result = $this->attendanceService->walkInRegister($program, $request->all(), $request); + + return match ($result['status']) { + 'success' => view('public.checkin.success', [ + 'program' => $program, + 'participant' => $result['participant'], + 'attendance' => $result['attendance'], + 'qrCode' => $qrCode, + ]), + 'already_checked_in' => view('public.checkin.already', [ + 'program' => $program, + 'participant' => $result['participant'], + 'attendance' => $result['attendance'], + ]), + default => back()->withInput()->with('error', 'Ralat sistem. Sila cuba lagi.'), + }; + } } diff --git a/app/Services/AttendanceService.php b/app/Services/AttendanceService.php new file mode 100644 index 0000000..3898b68 --- /dev/null +++ b/app/Services/AttendanceService.php @@ -0,0 +1,123 @@ +first(); + if (! $participant) { + return ['status' => 'not_found']; + } + + // Check if registered in this program + $pp = ProgramParticipant::where('program_id', $program->id) + ->where('participant_id', $participant->id) + ->first(); + + if (! $pp) { + return ['status' => 'not_registered', 'participant' => $participant]; + } + + // Already checked in + if (Attendance::where('program_id', $program->id)->where('participant_id', $participant->id)->exists()) { + $att = Attendance::where('program_id', $program->id)->where('participant_id', $participant->id)->first(); + return ['status' => 'already_checked_in', 'participant' => $participant, 'attendance' => $att]; + } + + // Record attendance + $attendance = DB::transaction(function () use ($program, $participant, $pp, $request) { + $session = $pp->pre_registered_session ?? $program->default_staff_session ?? 'full_day'; + + $pp->update(['status' => 'checked_in']); + + return Attendance::create([ + 'program_id' => $program->id, + 'participant_id' => $participant->id, + 'program_participant_id' => $pp->id, + 'attendance_source' => 'pre_registered_staff', + 'attendance_session' => $session, + 'checked_in_at' => now(), + 'checked_in_ip' => $request->ip(), + 'user_agent' => substr($request->userAgent() ?? '', 0, 500), + ]); + }); + + return ['status' => 'success', 'participant' => $participant, 'attendance' => $attendance]; + } + + public function walkInRegister(Program $program, array $data, Request $request): array + { + $noKp = preg_replace('/[^0-9]/', '', $data['no_kp']); + + // Check duplicate in this program + $existing = Participant::where('no_kp', $noKp)->first(); + if ($existing) { + $alreadyRegistered = ProgramParticipant::where('program_id', $program->id) + ->where('participant_id', $existing->id) + ->exists(); + if ($alreadyRegistered) { + // Check if already attended + $att = Attendance::where('program_id', $program->id) + ->where('participant_id', $existing->id) + ->first(); + if ($att) { + return ['status' => 'already_checked_in', 'participant' => $existing, 'attendance' => $att]; + } + } + } + + $result = DB::transaction(function () use ($program, $data, $noKp, $existing, $request) { + $participant = $existing ?? Participant::create([ + 'name' => $data['name'], + 'no_kp' => $noKp, + 'email' => $data['email'] ?: null, + 'phone' => $data['phone'] ?: null, + 'agency' => $data['agency'] ?: null, + 'participant_type' => 'external', + ]); + + // Create program_participant if not exists + $pp = ProgramParticipant::firstOrCreate( + ['program_id' => $program->id, 'participant_id' => $participant->id], + [ + 'registration_source' => 'walk_in', + 'is_pre_registered' => false, + 'pre_registered_session'=> $program->default_external_session, + 'status' => 'registered', + 'registered_at' => now(), + ] + ); + + $session = $program->default_external_session ?? 'full_day'; + + $pp->update(['status' => 'checked_in']); + + $attendance = Attendance::create([ + 'program_id' => $program->id, + 'participant_id' => $participant->id, + 'program_participant_id' => $pp->id, + 'attendance_source' => 'walk_in_external', + 'attendance_session' => $session, + 'checked_in_at' => now(), + 'checked_in_ip' => $request->ip(), + 'user_agent' => substr($request->userAgent() ?? '', 0, 500), + ]); + + return ['status' => 'success', 'participant' => $participant, 'attendance' => $attendance]; + }); + + return $result; + } +} diff --git a/resources/views/public/checkin/already.blade.php b/resources/views/public/checkin/already.blade.php new file mode 100644 index 0000000..252dd5c --- /dev/null +++ b/resources/views/public/checkin/already.blade.php @@ -0,0 +1,31 @@ +@extends('layouts.public') + +@section('title', 'Sudah Check-In') + +@section('hero') +

{{ $program->title }}

+
{{ $program->start_date->format('d M Y') }}
+@endsection + +@section('content') +
+
+
+ +
+
Sudah Check-In
+

Kehadiran anda sudah direkodkan sebelum ini.

+
+
+
+
Nama
+
{{ $participant->name }}
+
Masa Check-In
+
{{ $attendance->checked_in_at->format('d M Y, H:i') }}
+
Sesi
+
{{ Str::ucfirst($attendance->attendance_session) }}
+
+
+
+@endsection diff --git a/resources/views/public/checkin/show.blade.php b/resources/views/public/checkin/show.blade.php new file mode 100644 index 0000000..876242f --- /dev/null +++ b/resources/views/public/checkin/show.blade.php @@ -0,0 +1,152 @@ +@extends('layouts.public') + +@section('title', $program->title . ' — Check-In') + +@section('hero') +

{{ $program->title }}

+
+ {{ $program->location }} +  ·  + {{ $program->start_date->format('d M Y') }} +
+@endsection + +@section('content') + +@if(session('error')) + +@endif + +{{-- Pilihan Jenis Peserta --}} +
+
Sila pilih jenis peserta anda:
+
+
+ +
+ @if($program->allow_walk_in) +
+ +
+ @endif +
+
+ +{{-- Form: Kakitangan --}} +
+
+ Check-In Kakitangan +
+
+ @csrf +
+ + +
12 digit tanpa sempang
+ @error('no_kp')
{{ $message }}
@enderror +
+ +
+
+ +{{-- Form: Orang Luar --}} +@if($program->allow_walk_in) +
+
+ Daftar & Check-In +
+
+ @csrf +
+ + + @error('name')
{{ $message }}
@enderror +
+
+ + +
12 digit tanpa sempang
+ @error('no_kp')
{{ $message }}
@enderror +
+
+ + +
Untuk penerimaan sijil
+ @error('email')
{{ $message }}
@enderror +
+
+ + + @error('phone')
{{ $message }}
@enderror +
+
+ + + @error('agency')
{{ $message }}
@enderror +
+ +
+
+@endif + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/public/checkin/success.blade.php b/resources/views/public/checkin/success.blade.php new file mode 100644 index 0000000..f7392a9 --- /dev/null +++ b/resources/views/public/checkin/success.blade.php @@ -0,0 +1,50 @@ +@extends('layouts.public') + +@section('title', 'Check-In Berjaya') + +@section('hero') +

{{ $program->title }}

+
+ {{ $program->location }} +
+@endsection + +@section('content') + +
+
+
+ +
+
Check-In Berjaya!
+

Kehadiran anda telah direkodkan.

+
+ +
+
+
Nama
+
{{ $participant->name }}
+
Agensi
+
{{ $participant->agency ?: '—' }}
+
Sesi
+
+ {{ Str::ucfirst($attendance->attendance_session) }} +
+
Masa Check-In
+
{{ $attendance->checked_in_at->format('d M Y, H:i') }}
+
+
+ + +
+ +@endsection diff --git a/resources/views/public/checkin/unavailable.blade.php b/resources/views/public/checkin/unavailable.blade.php new file mode 100644 index 0000000..d919ff7 --- /dev/null +++ b/resources/views/public/checkin/unavailable.blade.php @@ -0,0 +1,23 @@ +@extends('layouts.public') + +@section('title', 'Tidak Tersedia') + +@section('hero') +

{{ $program->title }}

+
{{ $program->start_date->format('d M Y') }}
+@endsection + +@section('content') +
+
+
+ +
+
{{ $message ?? 'Tidak Tersedia' }}
+

+ Sila hubungi penganjur program untuk maklumat lanjut. +

+
+
+@endsection diff --git a/resources/views/public/semak/result.blade.php b/resources/views/public/semak/result.blade.php new file mode 100644 index 0000000..8d40112 --- /dev/null +++ b/resources/views/public/semak/result.blade.php @@ -0,0 +1,90 @@ +@extends('layouts.public') + +@section('title', 'Hasil Semakan — ' . $program->title) + +@section('hero') +

{{ $program->title }}

+
+ {{ $program->start_date->format('d M Y') }} +
+@endsection + +@section('content') + +@if(! $found) +{{-- Not found --}} +
+
+ +
+
Tiada Rekod Kehadiran
+

+ No. Kad Pengenalan yang dimasukkan tidak dijumpai dalam senarai kehadiran program ini. +

+ + Cuba Semula + +
+ +@else +{{-- Found --}} +
+
+ +
+
Kehadiran Disahkan
+

Rekod kehadiran anda dijumpai.

+ +
+
+
Nama
+
{{ $participant->name }}
+
Sesi
+
{{ Str::ucfirst($attendance->attendance_session) }}
+
Masa Check-In
+
{{ $attendance->checked_in_at->format('d/m/Y H:i') }}
+
+
+
+ +{{-- Certificate status --}} +@if($program->isDownloadOpen()) + @if($certificate && $certificate->isGenerated()) +
+ +
Sijil Sedia Dimuat Turun
+

Klik butang di bawah untuk dapatkan sijil anda.

+ + Muat Turun Sijil + +
+ @elseif($certificate && $certificate->status === 'pending') +
+ + Sijil sedang disediakan. Sila semak emel anda atau cuba lagi sebentar. +
+ @else +
+ + Link muat turun sijil akan dihantar ke emel anda. + @if($participant->email) + {{ $participant->email }} + @endif +
+ @endif +@else +
+ + Sijil belum boleh dimuat turun. + @if($program->ecert_download_start_at) + Mula pada {{ $program->ecert_download_start_at->format('d M Y, H:i') }}. + @endif +
+@endif + +@endif + +@endsection diff --git a/resources/views/public/semak/show.blade.php b/resources/views/public/semak/show.blade.php new file mode 100644 index 0000000..e14f1b2 --- /dev/null +++ b/resources/views/public/semak/show.blade.php @@ -0,0 +1,45 @@ +@extends('layouts.public') + +@section('title', 'Semak Kehadiran — ' . $program->title) + +@section('hero') +

{{ $program->title }}

+
+ {{ $program->start_date->format('d M Y') }} +  ·  + {{ $program->location }} +
+@endsection + +@section('content') + +@if($program->isDownloadOpen()) +
+ + Sijil boleh dimuat turun! + Masukkan No. Kad Pengenalan untuk semak status sijil anda. +
+@endif + +
+
+ Semak Kehadiran & Sijil +
+
+ @csrf +
+ + +
12 digit tanpa sempang
+ @error('no_kp')
{{ $message }}
@enderror +
+ +
+
+ +@endsection