diff --git a/app/Http/Controllers/Admin/ParticipantController.php b/app/Http/Controllers/Admin/ParticipantController.php index c2e8986..14edc40 100644 --- a/app/Http/Controllers/Admin/ParticipantController.php +++ b/app/Http/Controllers/Admin/ParticipantController.php @@ -3,9 +3,178 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Models\Participant; +use App\Models\Program; +use App\Models\ProgramParticipant; +use App\Services\AuditLogService; +use App\Services\ParticipantImportService; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; +use Illuminate\View\View; +use League\Csv\Writer; +use SplTempFileObject; class ParticipantController extends Controller { - // + public function index(Program $program, Request $request): View + { + $query = $program->programParticipants() + ->with('participant') + ->latest(); + + if ($request->filled('search')) { + $query->whereHas('participant', function ($q) use ($request) { + $q->where('name', 'like', '%' . $request->search . '%') + ->orWhere('agency', 'like', '%' . $request->search . '%'); + }); + } + + if ($request->filled('source')) { + $query->where('registration_source', $request->source); + } + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $programParticipants = $query->paginate(20)->withQueryString(); + + $counts = [ + 'total' => $program->programParticipants()->count(), + 'pre_registered' => $program->programParticipants()->where('is_pre_registered', true)->count(), + 'walk_in' => $program->programParticipants()->where('registration_source', 'walk_in')->count(), + 'checked_in' => $program->programParticipants()->where('status', 'checked_in')->count(), + ]; + + return view('admin.programs.participants.index', compact('program', 'programParticipants', 'counts')); + } + + public function create(Program $program): View + { + return view('admin.programs.participants.create', compact('program')); + } + + public function store(Program $program, Request $request): RedirectResponse + { + $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'no_kp' => ['required', 'string', 'regex:/^\d{6}-?\d{2}-?\d{4}$|^\d{12}$/'], + 'email' => ['nullable', 'email', 'max:255'], + 'phone' => ['nullable', 'string', 'max:20'], + 'agency' => ['nullable', 'string', 'max:255'], + 'session' => ['nullable', 'in:pagi,petang,full_day'], + ]); + + $noKp = preg_replace('/[^0-9]/', '', $request->no_kp); + + // Check duplicate in this program + $existing = Participant::where('no_kp', $noKp)->first(); + if ($existing && $program->programParticipants()->where('participant_id', $existing->id)->exists()) { + return back()->withInput()->with('error', 'Peserta dengan No. K/P ini sudah didaftarkan dalam program ini.'); + } + + DB::transaction(function () use ($program, $request, $noKp, $existing) { + $participant = $existing ?? Participant::create([ + 'name' => $request->name, + 'no_kp' => $noKp, + 'email' => $request->email, + 'phone' => $request->phone, + 'agency' => $request->agency, + 'participant_type' => 'staff', + ]); + + $program->programParticipants()->create([ + 'participant_id' => $participant->id, + 'registration_source' => 'admin_manual', + 'is_pre_registered' => true, + 'pre_registered_session'=> $request->session ?? $program->default_staff_session, + 'status' => 'registered', + 'registered_at' => now(), + ]); + + AuditLogService::log('participant.added', $participant); + }); + + return back()->with('success', 'Peserta berjaya ditambah.'); + } + + public function destroy(Program $program, ProgramParticipant $pp): RedirectResponse + { + if ($pp->program_id !== $program->id) { + abort(403); + } + + if ($pp->attendance()->exists()) { + return back()->with('error', 'Peserta tidak boleh dikeluarkan kerana sudah ada rekod kehadiran.'); + } + + $pp->delete(); + + return back()->with('success', 'Peserta berjaya dikeluarkan daripada program.'); + } + + public function importForm(Program $program): View + { + return view('admin.programs.participants.import', compact('program')); + } + + public function import(Program $program, Request $request, ParticipantImportService $importer): RedirectResponse + { + $request->validate([ + 'csv_file' => ['required', 'file', 'mimes:csv,txt', 'max:5120'], + 'session' => ['nullable', 'in:pagi,petang,full_day'], + ]); + + $result = $importer->import( + $program, + $request->file('csv_file'), + $request->input('session', $program->default_staff_session) + ); + + AuditLogService::log('participant.imported', $program, [], [ + 'success' => $result['success'], + 'duplicates' => $result['duplicates'], + 'failed' => $result['failed'], + ]); + + return back()->with('import_result', $result); + } + + public function export(Program $program): \Symfony\Component\HttpFoundation\StreamedResponse + { + $headers = [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="peserta_' . $program->uuid . '_' . now()->format('Ymd') . '.csv"', + ]; + + return response()->stream(function () use ($program) { + $handle = fopen('php://output', 'w'); + + // UTF-8 BOM for Excel compatibility + fputs($handle, "\xEF\xBB\xBF"); + + fputcsv($handle, ['Nama', 'No K/P', 'Emel', 'Telefon', 'Agensi', 'Sesi', 'Sumber', 'Status', 'Tarikh Daftar']); + + $program->programParticipants() + ->with('participant') + ->lazy() + ->each(function ($pp) use ($handle) { + $p = $pp->participant; + fputcsv($handle, [ + $p->name, + $p->no_kp, + $p->email, + $p->phone, + $p->agency, + $pp->pre_registered_session ?? '—', + $pp->registration_source, + $pp->status, + $pp->registered_at?->format('d/m/Y H:i') ?? '—', + ]); + }); + + fclose($handle); + }, 200, $headers); + } } diff --git a/app/Services/ParticipantImportService.php b/app/Services/ParticipantImportService.php new file mode 100644 index 0000000..102a149 --- /dev/null +++ b/app/Services/ParticipantImportService.php @@ -0,0 +1,101 @@ + 0, 'duplicates' => 0, 'failed' => 0, 'errors' => []]; + + $csv = Reader::createFromPath($file->getRealPath(), 'r'); + $csv->setHeaderOffset(0); + + // Strip UTF-8 BOM if present (Excel-exported CSV) + $csv->setOutputBOM(''); + try { + $csv->addStreamFilter('convert.iconv.UTF-8/UTF-8'); + } catch (\Throwable) {} + + foreach ($csv->getRecords() as $rowNum => $row) { + $row = array_map('trim', $row); + + // Normalise header keys (lowercase, strip BOM) + $row = array_combine( + array_map(fn($k) => strtolower(preg_replace('/[\x{FEFF}\s]/u', '', $k)), array_keys($row)), + array_values($row) + ); + + $data = [ + 'name' => $row['name'] ?? $row['nama'] ?? '', + 'no_kp' => preg_replace('/[^0-9]/', '', $row['no_kp'] ?? $row['nokp'] ?? $row['ic'] ?? ''), + 'email' => $row['email'] ?? $row['emel'] ?? null, + 'phone' => $row['phone'] ?? $row['telefon'] ?? $row['phone'] ?? null, + 'agency' => $row['agency'] ?? $row['agensi'] ?? $row['jabatan'] ?? null, + ]; + + // Validate row + $validator = Validator::make($data, [ + 'name' => ['required', 'string', 'max:255'], + 'no_kp' => ['required', 'digits:12'], + 'email' => ['nullable', 'email', 'max:255'], + 'phone' => ['nullable', 'string', 'max:20'], + ]); + + if ($validator->fails()) { + $result['failed']++; + $result['errors'][] = 'Baris ' . ($rowNum + 2) . ': ' . implode(', ', $validator->errors()->all()); + continue; + } + + try { + DB::transaction(function () use ($program, $data, $defaultSession, &$result) { + // Find or create participant by no_kp + $participant = Participant::firstOrCreate( + ['no_kp' => $data['no_kp']], + [ + 'name' => $data['name'], + 'email' => $data['email'] ?: null, + 'phone' => $data['phone'] ?: null, + 'agency' => $data['agency'] ?: null, + 'participant_type' => 'staff', + ] + ); + + // Check duplicate in this program + $exists = $program->programParticipants() + ->where('participant_id', $participant->id) + ->exists(); + + if ($exists) { + $result['duplicates']++; + return; + } + + $program->programParticipants()->create([ + 'participant_id' => $participant->id, + 'registration_source' => 'import', + 'is_pre_registered' => true, + 'pre_registered_session' => $defaultSession, + 'status' => 'registered', + 'registered_at' => now(), + ]); + + $result['success']++; + }); + } catch (\Throwable $e) { + $result['failed']++; + $result['errors'][] = 'Baris ' . ($rowNum + 2) . ': Ralat sistem — ' . $e->getMessage(); + } + } + + return $result; + } +} diff --git a/resources/views/admin/programs/participants/create.blade.php b/resources/views/admin/programs/participants/create.blade.php new file mode 100644 index 0000000..8c8f8d6 --- /dev/null +++ b/resources/views/admin/programs/participants/create.blade.php @@ -0,0 +1,87 @@ +@extends('layouts.admin') + +@section('title', 'Tambah Peserta') +@section('header', 'Tambah Peserta') + +@section('breadcrumb') +
Header wajib (baris pertama):
+name,no_kp,email,phone,agency
+
+ Contoh data:
+
+ Ahmad Ali,900101011234,ahmad@mbip.gov.my,0123456789,IT
+ Siti Binti Omar,850505055678,,0198765432,Kewangan
+
+
+ No. K/P: 12 digit tanpa sempang.
+Emel, telefon, agensi: boleh kosong.
+Duplikasi dalam program akan dilangkau.
+Ralat satu baris tidak hentikan import keseluruhan.
+| # | +Nama | +Agensi | +Sesi | +Sumber | +Status | +Tindakan | +
|---|---|---|---|---|---|---|
| {{ $programParticipants->firstItem() + $i }} | +
+ {{ $p->name }}
+ {{ $p->email ?: '—' }}
+ |
+ {{ $p->agency ?: '—' }} | ++ @if($pp->pre_registered_session) + + {{ Str::ucfirst($pp->pre_registered_session) }} + + @else + — + @endif + | ++ @php + $sourceMap = [ + 'pre_registered' => ['secondary', 'Pra-Daftar'], + 'import' => ['info', 'Import'], + 'walk_in' => ['warning', 'Walk-In'], + 'admin_manual' => ['dark', 'Manual'], + ]; + [$sc, $sl] = $sourceMap[$pp->registration_source] ?? ['light', $pp->registration_source]; + @endphp + {{ $sl }} + | ++ @if($pp->status === 'checked_in') + Hadir + @elseif($pp->status === 'cancelled') + Dibatalkan + @else + Berdaftar + @endif + | ++ @if($pp->status !== 'checked_in') + + @endif + | +