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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user