tambah fungsi upload peserta sebagai hadir

This commit is contained in:
Saufi
2026-05-20 20:10:43 +08:00
parent 154b2c650e
commit 7e4bbca2db
9 changed files with 1109 additions and 34 deletions

View File

@@ -112,6 +112,58 @@ class ParticipantController extends Controller
return back()->with('success', 'Peserta berjaya ditambah.');
}
public function edit(Program $program, ProgramParticipant $pp, Request $request): View
{
if ($pp->program_id !== $program->id) {
abort(403);
}
$pp->load('participant');
$filters = $request->only(['search', 'source', 'status', 'page']);
return view('admin.programs.participants.edit', compact('program', 'pp', 'filters'));
}
public function update(Program $program, ProgramParticipant $pp, Request $request): RedirectResponse
{
if ($pp->program_id !== $program->id) {
abort(403);
}
$request->validate([
'name' => ['required', 'string', 'max:255'],
'no_kp' => ['required', 'string', 'regex:/^\d{12}$/', 'unique:participants,no_kp,' . $pp->participant_id],
'email' => ['nullable', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
'agency' => ['nullable', 'string', 'max:255'],
'session' => ['nullable', 'in:pagi,petang,full_day'],
]);
$pp->load('participant');
DB::transaction(function () use ($pp, $request) {
$pp->participant->update([
'name' => $request->name,
'no_kp' => preg_replace('/[^0-9]/', '', $request->no_kp),
'email' => $request->email ?: null,
'phone' => $request->phone ?: null,
'agency' => $request->agency ?: null,
]);
$pp->update([
'pre_registered_session' => $request->session ?: null,
]);
});
AuditLogService::log('participant.updated', $pp->participant);
$filters = array_filter($request->only(['search', 'source', 'status', 'page']));
$indexUrl = route('admin.programs.participants.index', $program)
. ($filters ? '?' . http_build_query($filters) : '');
return redirect($indexUrl)->with('success', 'Maklumat peserta berjaya dikemaskini.');
}
public function destroy(Program $program, ProgramParticipant $pp): RedirectResponse
{
if ($pp->program_id !== $program->id) {
@@ -129,31 +181,52 @@ class ParticipantController extends Controller
public function importForm(Program $program): View
{
return view('admin.programs.participants.import', compact('program'));
$cutoff = $program->checkin_end_at ?? $program->end_date->endOfDay();
$programEnded = now()->gt($cutoff);
return view('admin.programs.participants.import', compact('program', 'programEnded'));
}
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'],
'csv_file' => ['required', 'file', 'mimes:csv,txt', 'max:5120'],
'session' => ['nullable', 'in:pagi,petang,full_day'],
'mark_attendance' => ['nullable', 'boolean'],
]);
$cutoff = $program->checkin_end_at ?? $program->end_date->endOfDay();
$markAttendance = now()->gt($cutoff) && $request->boolean('mark_attendance');
$result = $importer->import(
$program,
$request->file('csv_file'),
$request->input('session', $program->default_staff_session)
$request->input('session', $program->default_staff_session),
$markAttendance
);
AuditLogService::log('participant.imported', $program, [], [
'success' => $result['success'],
'duplicates' => $result['duplicates'],
'failed' => $result['failed'],
'success' => $result['success'],
'duplicates' => $result['duplicates'],
'failed' => $result['failed'],
'mark_attendance'=> $markAttendance,
]);
return back()->with('import_result', $result);
}
public function clearParticipants(Program $program): RedirectResponse
{
$deleted = $program->programParticipants()
->where('status', '!=', 'checked_in')
->whereDoesntHave('attendance')
->delete();
return redirect()
->route('admin.programs.participants.import.form', $program)
->with('success', "{$deleted} rekod peserta (belum hadir) telah dipadam.");
}
public function export(Program $program): \Symfony\Component\HttpFoundation\StreamedResponse
{
$headers = [

View File

@@ -2,8 +2,10 @@
namespace App\Services;
use App\Models\Attendance;
use App\Models\Participant;
use App\Models\Program;
use App\Models\ProgramParticipant;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
@@ -11,37 +13,54 @@ use League\Csv\Reader;
class ParticipantImportService
{
public function import(Program $program, UploadedFile $file, ?string $defaultSession): array
public function import(Program $program, UploadedFile $file, ?string $defaultSession, bool $markAttendance = false): array
{
$result = ['success' => 0, 'duplicates' => 0, 'failed' => 0, 'errors' => []];
$result = ['success' => 0, 'duplicates' => 0, 'failed' => 0, 'errors' => [], 'all_empty_ic' => false];
$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) {}
// Collect all rows first to detect all_empty_ic
$rows = [];
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)
);
$rows[$rowNum] = $row;
}
if (empty($rows)) {
return $result;
}
// If every row has an empty no_kp, offer delete instead
$noKpValues = array_map(
fn($row) => preg_replace('/[^0-9]/', '', $row['no_kp'] ?? $row['nokp'] ?? $row['ic'] ?? ''),
$rows
);
if (count(array_filter($noKpValues)) === 0) {
$result['all_empty_ic'] = true;
return $result;
}
$session = $defaultSession ?? $program->default_staff_session;
foreach ($rows as $rowNum => $row) {
$data = [
'name' => $row['name'] ?? $row['nama'] ?? '',
'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,
'email' => $row['email'] ?? $row['emel'] ?? null,
'phone' => $row['phone'] ?? $row['telefon'] ?? 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'],
@@ -56,8 +75,7 @@ class ParticipantImportService
}
try {
DB::transaction(function () use ($program, $data, $defaultSession, &$result) {
// Find or create participant by no_kp
DB::transaction(function () use ($program, $data, $session, $markAttendance, &$result) {
$participant = Participant::firstOrCreate(
['no_kp' => $data['no_kp']],
[
@@ -69,25 +87,36 @@ class ParticipantImportService
]
);
// Check duplicate in this program
$exists = $program->programParticipants()
->where('participant_id', $participant->id)
->exists();
$pp = $program->programParticipants()
->where('participant_id', $participant->id)
->first();
if ($exists) {
$result['duplicates']++;
if ($pp) {
// Participant already registered
if ($markAttendance) {
$this->recordAttendance($program, $participant, $pp, $session);
$result['duplicates']++;
} else {
$result['duplicates']++;
}
return;
}
$program->programParticipants()->create([
$newStatus = $markAttendance ? 'checked_in' : 'registered';
$pp = $program->programParticipants()->create([
'participant_id' => $participant->id,
'registration_source' => 'import',
'is_pre_registered' => true,
'pre_registered_session' => $defaultSession,
'status' => 'registered',
'pre_registered_session' => $session,
'status' => $newStatus,
'registered_at' => now(),
]);
if ($markAttendance) {
$this->recordAttendance($program, $participant, $pp, $session);
}
$result['success']++;
});
} catch (\Throwable $e) {
@@ -98,4 +127,25 @@ class ParticipantImportService
return $result;
}
private function recordAttendance(Program $program, Participant $participant, ProgramParticipant $pp, ?string $session): void
{
$alreadyAttended = Attendance::where('program_id', $program->id)
->where('participant_id', $participant->id)
->exists();
if ($alreadyAttended) {
return;
}
$pp->update(['status' => 'checked_in']);
Attendance::create([
'program_id' => $program->id,
'participant_id' => $participant->id,
'program_participant_id' => $pp->id,
'attendance_source' => 'import',
'attendance_session' => $session ?? 'full_day',
'checked_in_at' => now(),
]);
}
}

View File

@@ -0,0 +1,17 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement("ALTER TABLE attendances MODIFY attendance_source ENUM('pre_registered_staff','walk_in_external','admin_manual','import') NOT NULL");
}
public function down(): void
{
DB::statement("ALTER TABLE attendances MODIFY attendance_source ENUM('pre_registered_staff','walk_in_external','admin_manual') NOT NULL");
}
};

29
src/package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "ecert",
"name": "src",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -16,6 +16,29 @@
"vite": "^8.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -67,7 +90,6 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@@ -920,7 +942,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -1092,7 +1113,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1123,7 +1143,6 @@
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",

View File

@@ -0,0 +1,93 @@
@extends('layouts.admin')
@section('title', 'Edit Peserta — ' . $pp->participant->name)
@section('header', 'Edit Maklumat 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">Edit</li>
@endsection
@section('content')
<div class="row justify-content-center">
<div class="col-lg-6">
<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-gear me-2 text-primary"></i>Kemaskini Maklumat Peserta
</span>
</div>
<div class="card-body">
<form method="POST" action="{{ route('admin.programs.participants.update', [$program, $pp]) }}">
@csrf @method('PUT')
<div class="mb-3">
<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', $pp->participant->name) }}" required>
@error('name')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<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', $pp->participant->no_kp) }}"
placeholder="12 digit tanpa sempang" required>
@error('no_kp')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label class="form-label fw-medium">Emel</label>
<input type="email" name="email" class="form-control @error('email') is-invalid @enderror"
value="{{ old('email', $pp->participant->email) }}"
placeholder="Kosongkan jika tiada">
@error('email')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<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', $pp->participant->phone) }}"
placeholder="Kosongkan jika tiada">
@error('phone')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<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', $pp->participant->agency) }}"
placeholder="Kosongkan jika tiada">
@error('agency')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
<div class="mb-4">
<label class="form-label fw-medium">Sesi</label>
<select name="session" class="form-select @error('session') is-invalid @enderror">
<option value=""> Tiada Sesi </option>
<option value="pagi" {{ old('session', $pp->pre_registered_session) === 'pagi' ? 'selected' : '' }}>Pagi</option>
<option value="petang" {{ old('session', $pp->pre_registered_session) === 'petang' ? 'selected' : '' }}>Petang</option>
<option value="full_day" {{ old('session', $pp->pre_registered_session) === 'full_day' ? 'selected' : '' }}>Sehari Penuh</option>
</select>
@error('session')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
@foreach($filters as $key => $value)
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endforeach
@php $backUrl = route('admin.programs.participants.index', $program) . ($filters ? '?' . http_build_query($filters) : ''); @endphp
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i> Simpan
</button>
<a href="{{ $backUrl }}" class="btn btn-outline-secondary">Batal</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -17,6 +17,30 @@
{{-- Import Result --}}
@if(session('import_result'))
@php $r = session('import_result'); @endphp
{{-- All IC empty offer delete --}}
@if(!empty($r['all_empty_ic']))
<div class="card border-0 shadow-sm mb-4 border-start border-4 border-warning">
<div class="card-body">
<h6 class="fw-semibold mb-2 text-warning">
<i class="bi bi-exclamation-triangle me-2"></i>Semua No. K/P Kosong
</h6>
<p class="small text-muted mb-3">
Fail CSV yang dimuat naik tidak mengandungi sebarang No. K/P yang sah.
Tiada rekod diimport. Adakah anda ingin <strong>memadam semua peserta belum hadir</strong> dalam program ini?
</p>
<form method="POST" action="{{ route('admin.programs.participants.clear', $program) }}">
@csrf @method('DELETE')
<button type="submit" class="btn btn-danger btn-sm"
onclick="return confirm('Padam semua peserta yang belum hadir? Tindakan ini tidak boleh dibatalkan.')">
<i class="bi bi-trash me-1"></i> Padam Peserta Belum Hadir
</button>
<a href="{{ route('admin.programs.participants.import.form', $program) }}"
class="btn btn-outline-secondary btn-sm ms-2">Batal</a>
</form>
</div>
</div>
@else
<div class="card border-0 shadow-sm mb-4 border-start border-4
{{ $r['failed'] > 0 ? 'border-warning' : 'border-success' }}">
<div class="card-body">
@@ -59,6 +83,7 @@
</div>
</div>
@endif
@endif
{{-- Upload Form --}}
<div class="card border-0 shadow-sm">
@@ -79,7 +104,7 @@
@error('csv_file')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
<div class="mb-4">
<div class="mb-3">
<label class="form-label fw-medium">Sesi Default</label>
<select name="session" class="form-select">
<option value=""> Ikut Tetapan Program </option>
@@ -90,6 +115,24 @@
<div class="form-text">Sesi yang akan digunakan untuk semua peserta dalam fail ini.</div>
</div>
@if($programEnded)
<div class="mb-4 p-3 bg-warning bg-opacity-10 rounded border border-warning-subtle">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="mark_attendance"
value="1" id="markAttendance">
<label class="form-check-label fw-medium" for="markAttendance">
Tandakan sebagai Data Kehadiran
</label>
</div>
<div class="small text-muted mt-1 ms-4">
<i class="bi bi-info-circle me-1"></i>
Tempoh check-in telah tamat pada <strong>{{ ($program->checkin_end_at ?? $program->end_date->endOfDay())->format('d M Y, H:i') }}</strong>.
Jika ditanda, semua peserta dalam fail ini akan direkodkan sebagai <strong>hadir</strong>.
Peserta sedia ada dalam program akan dikemaskini status kehadirannya.
</div>
</div>
@endif
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-upload me-2"></i>Mula Import
</button>

View File

@@ -201,6 +201,10 @@
<i class="bi bi-download"></i>
</a>
@endif
<a href="{{ route('admin.programs.participants.edit', [$program, $pp]) . (request()->hasAny(['search','source','status','page']) ? '?' . http_build_query(request()->only(['search','source','status','page'])) : '') }}"
class="btn btn-sm btn-outline-secondary" title="Edit Peserta">
<i class="bi bi-pencil"></i>
</a>
@if($pp->status !== 'checked_in')
<form method="POST"
action="{{ route('admin.programs.participants.destroy', [$program, $pp]) }}"

View File

@@ -53,9 +53,12 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
Route::get('/', [ParticipantController::class, 'index'])->name('index');
Route::get('/create', [ParticipantController::class, 'create'])->name('create');
Route::post('/', [ParticipantController::class, 'store'])->name('store');
Route::get('/{pp}/edit', [ParticipantController::class, 'edit'])->name('edit');
Route::put('/{pp}', [ParticipantController::class, 'update'])->name('update');
Route::delete('/{pp}', [ParticipantController::class, 'destroy'])->name('destroy');
Route::get('/import', [ParticipantController::class, 'importForm'])->name('import.form');
Route::post('/import', [ParticipantController::class, 'import'])->name('import');
Route::delete('/clear', [ParticipantController::class, 'clearParticipants'])->name('clear');
Route::get('/export', [ParticipantController::class, 'export'])->name('export');
});