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 <noreply@anthropic.com>
This commit is contained in:
Saufi
2026-05-16 20:20:27 +08:00
parent 32428733d6
commit d0ebaf8433
9 changed files with 674 additions and 2 deletions

View File

@@ -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);
}
}

View File

@@ -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.'),
};
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Services;
use App\Models\Attendance;
use App\Models\Participant;
use App\Models\Program;
use App\Models\ProgramParticipant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AttendanceService
{
public function staffCheckin(Program $program, string $noKp, Request $request): array
{
$noKp = preg_replace('/[^0-9]/', '', $noKp);
// Find participant
$participant = Participant::where('no_kp', $noKp)->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;
}
}