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;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
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\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use League\Csv\Writer;
|
||||||
|
use SplTempFileObject;
|
||||||
|
|
||||||
class ParticipantController extends Controller
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
app/Services/ParticipantImportService.php
Normal file
101
app/Services/ParticipantImportService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
resources/views/admin/programs/participants/create.blade.php
Normal file
87
resources/views/admin/programs/participants/create.blade.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
@extends('layouts.admin')
|
||||||
|
|
||||||
|
@section('title', 'Tambah Peserta')
|
||||||
|
@section('header', 'Tambah Peserta')
|
||||||
|
|
||||||
|
@section('breadcrumb')
|
||||||
|
<li class="breadcrumb-item"><a href="{{ route('admin.programs.index') }}">Program</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ route('admin.programs.show', $program) }}">{{ Str::limit($program->title, 25) }}</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ route('admin.programs.participants.index', $program) }}">Peserta</a></li>
|
||||||
|
<li class="breadcrumb-item active">Tambah</li>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom py-3">
|
||||||
|
<span class="fw-semibold">
|
||||||
|
<i class="bi bi-person-plus me-2 text-primary"></i>Tambah Peserta Pra-Daftar
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ route('admin.programs.participants.store', $program) }}">
|
||||||
|
@csrf
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label fw-medium">Nama Penuh <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="name" class="form-control @error('name') is-invalid @enderror"
|
||||||
|
value="{{ old('name') }}" placeholder="Contoh: Ahmad bin Ali">
|
||||||
|
@error('name')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">No. Kad Pengenalan <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="no_kp" class="form-control @error('no_kp') is-invalid @enderror"
|
||||||
|
value="{{ old('no_kp') }}" placeholder="900101011234"
|
||||||
|
maxlength="14">
|
||||||
|
<div class="form-text">Tanpa sempang. 12 digit.</div>
|
||||||
|
@error('no_kp')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Sesi</label>
|
||||||
|
<select name="session" class="form-select @error('session') is-invalid @enderror">
|
||||||
|
<option value="">— Pilih Sesi —</option>
|
||||||
|
<option value="pagi" {{ old('session', $program->default_staff_session) === 'pagi' ? 'selected' : '' }}>Pagi</option>
|
||||||
|
<option value="petang" {{ old('session', $program->default_staff_session) === 'petang' ? 'selected' : '' }}>Petang</option>
|
||||||
|
<option value="full_day" {{ old('session', $program->default_staff_session) === 'full_day' ? 'selected' : '' }}>Sehari Penuh</option>
|
||||||
|
</select>
|
||||||
|
@error('session')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">Emel</label>
|
||||||
|
<input type="email" name="email" class="form-control @error('email') is-invalid @enderror"
|
||||||
|
value="{{ old('email') }}" placeholder="ahmad@jabatan.gov.my">
|
||||||
|
@error('email')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-medium">No. Telefon</label>
|
||||||
|
<input type="text" name="phone" class="form-control @error('phone') is-invalid @enderror"
|
||||||
|
value="{{ old('phone') }}" placeholder="0123456789">
|
||||||
|
@error('phone')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label fw-medium">Jabatan / Agensi</label>
|
||||||
|
<input type="text" name="agency" class="form-control @error('agency') is-invalid @enderror"
|
||||||
|
value="{{ old('agency') }}" placeholder="Jabatan Sumber Manusia">
|
||||||
|
@error('agency')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||||
|
<a href="{{ route('admin.programs.participants.index', $program) }}"
|
||||||
|
class="btn btn-outline-secondary">Batal</a>
|
||||||
|
<button type="submit" class="btn btn-primary px-4">
|
||||||
|
<i class="bi bi-check-lg me-1"></i> Tambah Peserta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
136
resources/views/admin/programs/participants/import.blade.php
Normal file
136
resources/views/admin/programs/participants/import.blade.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
@extends('layouts.admin')
|
||||||
|
|
||||||
|
@section('title', 'Import Peserta CSV')
|
||||||
|
@section('header', 'Import Peserta CSV')
|
||||||
|
|
||||||
|
@section('breadcrumb')
|
||||||
|
<li class="breadcrumb-item"><a href="{{ route('admin.programs.index') }}">Program</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ route('admin.programs.show', $program) }}">{{ Str::limit($program->title, 25) }}</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ route('admin.programs.participants.index', $program) }}">Peserta</a></li>
|
||||||
|
<li class="breadcrumb-item active">Import CSV</li>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="row g-4 justify-content-center">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
|
||||||
|
{{-- Import Result --}}
|
||||||
|
@if(session('import_result'))
|
||||||
|
@php $r = session('import_result'); @endphp
|
||||||
|
<div class="card border-0 shadow-sm mb-4 border-start border-4
|
||||||
|
{{ $r['failed'] > 0 ? 'border-warning' : 'border-success' }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="fw-semibold mb-3">
|
||||||
|
<i class="bi bi-clipboard-check me-2 text-success"></i>Hasil Import
|
||||||
|
</h6>
|
||||||
|
<div class="row g-2 text-center mb-3">
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="p-2 bg-success bg-opacity-10 rounded">
|
||||||
|
<div class="fs-4 fw-bold text-success">{{ $r['success'] }}</div>
|
||||||
|
<div class="small text-muted">Berjaya</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="p-2 bg-warning bg-opacity-10 rounded">
|
||||||
|
<div class="fs-4 fw-bold text-warning">{{ $r['duplicates'] }}</div>
|
||||||
|
<div class="small text-muted">Duplikasi</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="p-2 bg-danger bg-opacity-10 rounded">
|
||||||
|
<div class="fs-4 fw-bold text-danger">{{ $r['failed'] }}</div>
|
||||||
|
<div class="small text-muted">Gagal</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if(!empty($r['errors']))
|
||||||
|
<div class="mt-2">
|
||||||
|
<small class="text-muted fw-semibold">Ralat:</small>
|
||||||
|
<ul class="small text-danger mb-0 ps-3 mt-1">
|
||||||
|
@foreach(array_slice($r['errors'], 0, 10) as $err)
|
||||||
|
<li>{{ $err }}</li>
|
||||||
|
@endforeach
|
||||||
|
@if(count($r['errors']) > 10)
|
||||||
|
<li class="text-muted">...dan {{ count($r['errors']) - 10 }} ralat lagi.</li>
|
||||||
|
@endif
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Upload Form --}}
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom py-3">
|
||||||
|
<span class="fw-semibold">
|
||||||
|
<i class="bi bi-upload me-2 text-primary"></i>Muat Naik Fail CSV
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ route('admin.programs.participants.import', $program) }}"
|
||||||
|
enctype="multipart/form-data">
|
||||||
|
@csrf
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-medium">Fail CSV <span class="text-danger">*</span></label>
|
||||||
|
<input type="file" name="csv_file" accept=".csv,.txt"
|
||||||
|
class="form-control @error('csv_file') is-invalid @enderror">
|
||||||
|
<div class="form-text">Saiz maksimum: 5MB. Format: CSV (UTF-8)</div>
|
||||||
|
@error('csv_file')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-medium">Sesi Default</label>
|
||||||
|
<select name="session" class="form-select">
|
||||||
|
<option value="">— Ikut Tetapan Program —</option>
|
||||||
|
<option value="pagi" {{ $program->default_staff_session === 'pagi' ? 'selected' : '' }}>Pagi</option>
|
||||||
|
<option value="petang" {{ $program->default_staff_session === 'petang' ? 'selected' : '' }}>Petang</option>
|
||||||
|
<option value="full_day" {{ $program->default_staff_session === 'full_day' ? 'selected' : '' }}>Sehari Penuh</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Sesi yang akan digunakan untuk semua peserta dalam fail ini.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-upload me-2"></i>Mula Import
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Format Guide --}}
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom py-3">
|
||||||
|
<span class="fw-semibold">
|
||||||
|
<i class="bi bi-file-earmark-text me-2 text-primary"></i>Format CSV
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="small text-muted mb-2">Header wajib (baris pertama):</p>
|
||||||
|
<code class="d-block bg-light p-2 rounded small mb-3">name,no_kp,email,phone,agency</code>
|
||||||
|
|
||||||
|
<p class="small text-muted mb-2">Contoh data:</p>
|
||||||
|
<code class="d-block bg-light p-2 rounded small mb-3" style="font-size:.75rem; word-break:break-all;">
|
||||||
|
Ahmad Ali,900101011234,ahmad@mbip.gov.my,0123456789,IT<br>
|
||||||
|
Siti Binti Omar,850505055678,,0198765432,Kewangan
|
||||||
|
</code>
|
||||||
|
|
||||||
|
<div class="small text-muted">
|
||||||
|
<p class="mb-1"><i class="bi bi-info-circle text-info me-1"></i>No. K/P: 12 digit tanpa sempang.</p>
|
||||||
|
<p class="mb-1"><i class="bi bi-info-circle text-info me-1"></i>Emel, telefon, agensi: boleh kosong.</p>
|
||||||
|
<p class="mb-1"><i class="bi bi-shield-check text-success me-1"></i>Duplikasi dalam program akan dilangkau.</p>
|
||||||
|
<p class="mb-0"><i class="bi bi-shield-check text-success me-1"></i>Ralat satu baris tidak hentikan import keseluruhan.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 pt-3 border-top">
|
||||||
|
<a href="{{ route('admin.programs.participants.export', $program) }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm w-100">
|
||||||
|
<i class="bi bi-download me-1"></i> Muat Turun Senarai Semasa
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
172
resources/views/admin/programs/participants/index.blade.php
Normal file
172
resources/views/admin/programs/participants/index.blade.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
@extends('layouts.admin')
|
||||||
|
|
||||||
|
@section('title', 'Peserta — ' . $program->title)
|
||||||
|
@section('header', 'Senarai Peserta')
|
||||||
|
|
||||||
|
@section('breadcrumb')
|
||||||
|
<li class="breadcrumb-item"><a href="{{ route('admin.programs.index') }}">Program</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ route('admin.programs.show', $program) }}">{{ Str::limit($program->title, 25) }}</a></li>
|
||||||
|
<li class="breadcrumb-item active">Peserta</li>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('header-actions')
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ route('admin.programs.participants.export', $program) }}" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="bi bi-download me-1"></i> Export CSV
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.programs.participants.import.form', $program) }}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-upload me-1"></i> Import CSV
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.programs.participants.create', $program) }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-person-plus me-1"></i> Tambah
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
{{-- Summary Cards --}}
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
@foreach([
|
||||||
|
['label' => 'Jumlah Peserta', 'value' => $counts['total'], 'icon' => 'bi-people', 'color' => 'primary'],
|
||||||
|
['label' => 'Pra-Daftar', 'value' => $counts['pre_registered'], 'icon' => 'bi-person-check', 'color' => 'info'],
|
||||||
|
['label' => 'Walk-In', 'value' => $counts['walk_in'], 'icon' => 'bi-person-walking', 'color' => 'warning'],
|
||||||
|
['label' => 'Hadir', 'value' => $counts['checked_in'], 'icon' => 'bi-patch-check', 'color' => 'success'],
|
||||||
|
] as $card)
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card border-0 shadow-sm text-center p-3">
|
||||||
|
<i class="bi {{ $card['icon'] }} text-{{ $card['color'] }} fs-4 mb-1"></i>
|
||||||
|
<div class="fs-4 fw-bold">{{ $card['value'] }}</div>
|
||||||
|
<div class="text-muted small">{{ $card['label'] }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Filter --}}
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<form method="GET" class="row g-2 align-items-end">
|
||||||
|
<div class="col-sm-5">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" name="search" class="form-control"
|
||||||
|
placeholder="Nama, agensi..." value="{{ request('search') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-3">
|
||||||
|
<select name="source" class="form-select form-select-sm">
|
||||||
|
<option value="">Semua Sumber</option>
|
||||||
|
<option value="pre_registered" {{ request('source') === 'pre_registered' ? 'selected' : '' }}>Pra-Daftar</option>
|
||||||
|
<option value="import" {{ request('source') === 'import' ? 'selected' : '' }}>Import</option>
|
||||||
|
<option value="walk_in" {{ request('source') === 'walk_in' ? 'selected' : '' }}>Walk-In</option>
|
||||||
|
<option value="admin_manual" {{ request('source') === 'admin_manual' ? 'selected' : '' }}>Manual Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-2">
|
||||||
|
<select name="status" class="form-select form-select-sm">
|
||||||
|
<option value="">Semua Status</option>
|
||||||
|
<option value="registered" {{ request('status') === 'registered' ? 'selected' : '' }}>Berdaftar</option>
|
||||||
|
<option value="checked_in" {{ request('status') === 'checked_in' ? 'selected' : '' }}>Hadir</option>
|
||||||
|
<option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }}>Dibatalkan</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Tapis</button>
|
||||||
|
@if(request()->hasAny(['search', 'source', 'status']))
|
||||||
|
<a href="{{ route('admin.programs.participants.index', $program) }}" class="btn btn-outline-secondary btn-sm ms-1">Reset</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Table --}}
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if($programParticipants->isEmpty())
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-people fs-1 opacity-25 d-block mb-2"></i>
|
||||||
|
Belum ada peserta.
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Nama</th>
|
||||||
|
<th>Agensi</th>
|
||||||
|
<th>Sesi</th>
|
||||||
|
<th>Sumber</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Tindakan</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($programParticipants as $i => $pp)
|
||||||
|
@php $p = $pp->participant; @endphp
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted small">{{ $programParticipants->firstItem() + $i }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-medium">{{ $p->name }}</div>
|
||||||
|
<small class="text-muted">{{ $p->email ?: '—' }}</small>
|
||||||
|
</td>
|
||||||
|
<td><small>{{ $p->agency ?: '—' }}</small></td>
|
||||||
|
<td>
|
||||||
|
@if($pp->pre_registered_session)
|
||||||
|
<span class="badge bg-light text-dark border">
|
||||||
|
{{ Str::ucfirst($pp->pre_registered_session) }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@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
|
||||||
|
<span class="badge bg-{{ $sc }}">{{ $sl }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($pp->status === 'checked_in')
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-lg me-1"></i>Hadir</span>
|
||||||
|
@elseif($pp->status === 'cancelled')
|
||||||
|
<span class="badge bg-danger">Dibatalkan</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-light text-dark border">Berdaftar</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
@if($pp->status !== 'checked_in')
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ route('admin.programs.participants.destroy', [$program, $pp]) }}"
|
||||||
|
onsubmit="return confirm('Keluarkan peserta {{ $p->name }} daripada program?')">
|
||||||
|
@csrf @method('DELETE')
|
||||||
|
<button class="btn btn-sm btn-outline-danger" title="Keluarkan">
|
||||||
|
<i class="bi bi-person-dash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if($programParticipants->hasPages())
|
||||||
|
<div class="px-3 py-3 border-top">
|
||||||
|
{{ $programParticipants->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
Reference in New Issue
Block a user