feat: participant management and csv import

- ParticipantController: list (search/filter), add manual, remove, export CSV (UTF-8 BOM)
- ParticipantImportService: League\Csv, strip BOM, normalise headers, per-row validation,
  duplicate detection, transaction per row (single failure does not abort import), summary report
- Participant index: counts (total/pre-reg/walk-in/hadir), filter by source+status, pagination
- Participant create: inline no_kp validation, session picker pre-filled from program default
- Import page: result summary (success/duplicates/failed), error list, format guide

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Saufi
2026-05-16 20:02:05 +08:00
parent 12324091dd
commit 32428733d6
5 changed files with 666 additions and 1 deletions

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Services;
use App\Models\Participant;
use App\Models\Program;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use League\Csv\Reader;
class ParticipantImportService
{
public function import(Program $program, UploadedFile $file, ?string $defaultSession): array
{
$result = ['success' => 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;
}
}