diff --git a/app/Http/Controllers/Admin/ProgramController.php b/app/Http/Controllers/Admin/ProgramController.php index 065818c..e64a8b3 100644 --- a/app/Http/Controllers/Admin/ProgramController.php +++ b/app/Http/Controllers/Admin/ProgramController.php @@ -3,63 +3,135 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\StoreProgramRequest; +use App\Http\Requests\Admin\UpdateProgramRequest; +use App\Models\Program; +use App\Services\AuditLogService; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\View\View; class ProgramController extends Controller { - /** - * Display a listing of the resource. - */ - public function index() + public function index(Request $request): View { - // + $query = Program::with('creator') + ->withCount(['attendances', 'programParticipants']) + ->latest(); + + if ($request->filled('search')) { + $query->where(function ($q) use ($request) { + $q->where('title', 'like', '%' . $request->search . '%') + ->orWhere('organizer', 'like', '%' . $request->search . '%') + ->orWhere('location', 'like', '%' . $request->search . '%'); + }); + } + + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + $programs = $query->paginate(15)->withQueryString(); + + return view('admin.programs.index', compact('programs')); } - /** - * Show the form for creating a new resource. - */ - public function create() + public function create(): View { - // + return view('admin.programs.create'); } - /** - * Store a newly created resource in storage. - */ - public function store(Request $request) + public function store(StoreProgramRequest $request): RedirectResponse { - // + $program = Program::create([ + ...$request->validated(), + 'created_by' => auth()->id(), + ]); + + AuditLogService::log('program.created', $program); + + return redirect() + ->route('admin.programs.show', $program) + ->with('success', 'Program "' . $program->title . '" berjaya ditambah.'); } - /** - * Display the specified resource. - */ - public function show(string $id) + public function show(Program $program): View { - // + $program->load([ + 'qrCode', + 'certificateTemplate', + 'questionnaire.questionnaireSet.questions', + ]); + + $stats = [ + 'total_participants' => $program->programParticipants()->count(), + 'pre_registered' => $program->programParticipants()->where('is_pre_registered', true)->count(), + 'walk_in' => $program->programParticipants()->where('registration_source', 'walk_in')->count(), + 'total_attendances' => $program->attendances()->count(), + 'total_certificates' => $program->certificates()->count(), + 'generated_certificates'=> $program->certificates()->whereIn('status', ['generated', 'emailed', 'downloaded'])->count(), + ]; + + return view('admin.programs.show', compact('program', 'stats')); } - /** - * Show the form for editing the specified resource. - */ - public function edit(string $id) + public function edit(Program $program): View { - // + return view('admin.programs.edit', compact('program')); } - /** - * Update the specified resource in storage. - */ - public function update(Request $request, string $id) + public function update(UpdateProgramRequest $request, Program $program): RedirectResponse { - // + $old = $program->only([ + 'title', 'status', 'checkin_start_at', 'checkin_end_at', + 'ecert_download_start_at', 'ecert_download_end_at', + ]); + + $program->update($request->validated()); + + AuditLogService::log('program.updated', $program, $old); + + return redirect() + ->route('admin.programs.show', $program) + ->with('success', 'Maklumat program berjaya dikemas kini.'); } - /** - * Remove the specified resource from storage. - */ - public function destroy(string $id) + public function destroy(Program $program): RedirectResponse { - // + if ($program->attendances()->exists()) { + return back()->with('error', 'Program tidak boleh dipadam kerana sudah ada rekod kehadiran.'); + } + + $title = $program->title; + AuditLogService::log('program.deleted', $program); + $program->delete(); + + return redirect() + ->route('admin.programs.index') + ->with('success', 'Program "' . $title . '" berjaya dipadam.'); + } + + public function publish(Program $program): RedirectResponse + { + if ($program->status !== 'draft') { + return back()->with('error', 'Hanya program berstatus Draf boleh diterbitkan.'); + } + + $program->update(['status' => 'published']); + AuditLogService::log('program.published', $program); + + return back()->with('success', 'Program berjaya diterbitkan.'); + } + + public function close(Program $program): RedirectResponse + { + if ($program->status !== 'published') { + return back()->with('error', 'Hanya program berstatus Diterbitkan boleh ditutup.'); + } + + $program->update(['status' => 'closed']); + AuditLogService::log('program.closed', $program); + + return back()->with('success', 'Program berjaya ditutup.'); } } diff --git a/app/Http/Requests/Admin/StoreProgramRequest.php b/app/Http/Requests/Admin/StoreProgramRequest.php new file mode 100644 index 0000000..4b75d92 --- /dev/null +++ b/app/Http/Requests/Admin/StoreProgramRequest.php @@ -0,0 +1,56 @@ +check(); + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:5000'], + 'organizer' => ['required', 'string', 'max:255'], + 'location' => ['required', 'string', 'max:500'], + 'start_date' => ['required', 'date'], + 'end_date' => ['required', 'date', 'gte:start_date'], + 'checkin_start_at' => ['nullable', 'date'], + 'checkin_end_at' => ['nullable', 'date', 'after_or_equal:checkin_start_at'], + 'ecert_download_start_at' => ['nullable', 'date'], + 'ecert_download_end_at' => ['nullable', 'date', 'after_or_equal:ecert_download_start_at'], + 'allow_walk_in' => ['boolean'], + 'default_staff_session' => ['nullable', 'in:pagi,petang,full_day'], + 'default_external_session' => ['nullable', 'in:pagi,petang,full_day'], + ]; + } + + public function attributes(): array + { + return [ + 'title' => 'nama program', + 'organizer' => 'penganjur', + 'location' => 'lokasi', + 'start_date' => 'tarikh mula', + 'end_date' => 'tarikh tamat', + 'checkin_start_at' => 'masa mula check-in', + 'checkin_end_at' => 'masa tamat check-in', + 'ecert_download_start_at' => 'masa mula download sijil', + 'ecert_download_end_at' => 'masa tamat download sijil', + 'default_staff_session' => 'sesi default kakitangan', + 'default_external_session' => 'sesi default peserta luar', + ]; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'allow_walk_in' => $this->boolean('allow_walk_in'), + ]); + } +} diff --git a/app/Http/Requests/Admin/UpdateProgramRequest.php b/app/Http/Requests/Admin/UpdateProgramRequest.php new file mode 100644 index 0000000..6cfca8a --- /dev/null +++ b/app/Http/Requests/Admin/UpdateProgramRequest.php @@ -0,0 +1,9 @@ + Limit::perMinute(60)->by($request->ip()) diff --git a/app/Services/AuditLogService.php b/app/Services/AuditLogService.php new file mode 100644 index 0000000..f2855c6 --- /dev/null +++ b/app/Services/AuditLogService.php @@ -0,0 +1,39 @@ + auth()->id(), + 'action' => $action, + 'auditable_type' => $model ? get_class($model) : null, + 'auditable_id' => $model?->getKey(), + 'old_values' => self::redact($oldValues), + 'new_values' => self::redact($newValues), + 'ip_address' => request()->ip(), + 'user_agent' => substr(request()->userAgent() ?? '', 0, 500), + ]); + } catch (\Throwable) { + // Audit log failure must not break the main flow. + } + } + + private static function redact(array $values): array + { + // Never log these sensitive fields. + $sensitive = ['no_kp', 'password', 'token', 'remember_token']; + + return array_diff_key($values, array_flip($sensitive)); + } +} diff --git a/resources/views/admin/programs/_form.blade.php b/resources/views/admin/programs/_form.blade.php new file mode 100644 index 0000000..6fe660f --- /dev/null +++ b/resources/views/admin/programs/_form.blade.php @@ -0,0 +1,174 @@ +{{-- + Shared form partial. + Variables expected: + $program — Program model (or null for create) + $action — form action URL + $method — PUT for edit, omit for create (POST) +--}} +
+ @csrf + @isset($method) @method($method) @endisset + +
+ + {{-- ── Maklumat Asas ── --}} +
+
+
+ Maklumat Asas +
+
+
+
+ + + @error('title')
{{ $message }}
@enderror +
+ +
+ + + @error('organizer')
{{ $message }}
@enderror +
+ +
+ + + @error('location')
{{ $message }}
@enderror +
+ +
+ + + @error('description')
{{ $message }}
@enderror +
+
+
+
+
+ + {{-- ── Tarikh & Masa ── --}} +
+
+
+ Tarikh & Masa +
+
+
+
+ + + @error('start_date')
{{ $message }}
@enderror +
+
+ + + @error('end_date')
{{ $message }}
@enderror +
+ +

Tempoh Check-In (kosongkan jika tidak ditetapkan)

+ +
+ + + @error('checkin_start_at')
{{ $message }}
@enderror +
+
+ + + @error('checkin_end_at')
{{ $message }}
@enderror +
+ +

Tempoh Download eCert (kosongkan jika tidak ditetapkan)

+ +
+ + + @error('ecert_download_start_at')
{{ $message }}
@enderror +
+
+ + + @error('ecert_download_end_at')
{{ $message }}
@enderror +
+
+
+
+
+ + {{-- ── Tetapan Kehadiran ── --}} +
+
+
+ Tetapan Kehadiran +
+
+
+
+
+ + allow_walk_in ?? true) ? 'checked' : '' }}> + +
+
Orang luar boleh daftar sendiri semasa program.
+
+ +
+ + + @error('default_staff_session')
{{ $message }}
@enderror +
+ +
+ + + @error('default_external_session')
{{ $message }}
@enderror +
+
+
+
+
+ + {{-- ── Butang Submit ── --}} +
+ + Batal + + +
+
+
diff --git a/resources/views/admin/programs/create.blade.php b/resources/views/admin/programs/create.blade.php new file mode 100644 index 0000000..3466f8e --- /dev/null +++ b/resources/views/admin/programs/create.blade.php @@ -0,0 +1,15 @@ +@extends('layouts.admin') + +@section('title', 'Tambah Program Baru') +@section('header', 'Tambah Program Baru') + +@section('breadcrumb') + + +@endsection + +@section('content') +@include('admin.programs._form', [ + 'action' => route('admin.programs.store'), +]) +@endsection diff --git a/resources/views/admin/programs/edit.blade.php b/resources/views/admin/programs/edit.blade.php new file mode 100644 index 0000000..5c6a956 --- /dev/null +++ b/resources/views/admin/programs/edit.blade.php @@ -0,0 +1,18 @@ +@extends('layouts.admin') + +@section('title', 'Edit Program') +@section('header', 'Edit Program') + +@section('breadcrumb') + + + +@endsection + +@section('content') +@include('admin.programs._form', [ + 'program' => $program, + 'action' => route('admin.programs.update', $program), + 'method' => 'PUT', +]) +@endsection diff --git a/resources/views/admin/programs/index.blade.php b/resources/views/admin/programs/index.blade.php new file mode 100644 index 0000000..e1d309e --- /dev/null +++ b/resources/views/admin/programs/index.blade.php @@ -0,0 +1,175 @@ +@extends('layouts.admin') + +@section('title', 'Senarai Program') +@section('header', 'Senarai Program') + +@section('breadcrumb') + +@endsection + +@section('header-actions') + + Program Baru + +@endsection + +@section('content') + +{{-- Filter Bar --}} +
+
+
+
+ +
+ + +
+
+
+ + +
+
+ + @if(request()->hasAny(['search', 'status'])) + Reset + @endif +
+
+
+
+ +{{-- Table --}} +
+
+ @if($programs->isEmpty()) +
+ + @if(request()->hasAny(['search', 'status'])) + Tiada program sepadan dengan carian. + @else + Belum ada program. Tambah sekarang + @endif +
+ @else +
+ + + + + + + + + + + + + @foreach($programs as $program) + + + + + + + + + @endforeach + +
Nama ProgramTarikhKehadiranPesertaStatusTindakan
+
{{ $program->title }}
+ + {{ Str::limit($program->location, 40) }} + +
+
{{ $program->start_date->format('d M Y') }}
+ @if($program->start_date->ne($program->end_date)) +
- {{ $program->end_date->format('d M Y') }}
+ @endif +
+ + {{ $program->attendances_count }} + + + + {{ $program->program_participants_count }} + + + @include('admin.partials.program-status-badge', ['status' => $program->status]) + +
+ + + + @if($program->status === 'draft') + + + + @endif + @if($program->status === 'draft' && $program->attendances_count === 0) + + @endif +
+
+
+ + {{-- Pagination --}} + @if($programs->hasPages()) +
+ {{ $programs->links() }} +
+ @endif + @endif +
+
+ +{{-- Delete Confirm Modal --}} + + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/programs/show.blade.php b/resources/views/admin/programs/show.blade.php new file mode 100644 index 0000000..ce41938 --- /dev/null +++ b/resources/views/admin/programs/show.blade.php @@ -0,0 +1,285 @@ +@extends('layouts.admin') + +@section('title', $program->title) +@section('header', $program->title) + +@section('breadcrumb') + + +@endsection + +@section('header-actions') +
+ @if($program->status === 'draft') + + Edit + +
+ @csrf + +
+ @elseif($program->status === 'published') +
+ @csrf + +
+ @endif +
+@endsection + +@section('content') + +{{-- Program Info Card --}} +
+
+
+
+
+
+
{{ $program->title }}
+ {{ $program->organizer }} +
+ @include('admin.partials.program-status-badge', ['status' => $program->status]) +
+ +
+
+
Tarikh
+
{{ $program->start_date->format('d M Y') }} + @if($program->start_date->ne($program->end_date)) + — {{ $program->end_date->format('d M Y') }} + @endif +
+
+
+
Lokasi
+
{{ $program->location }}
+
+ @if($program->checkin_start_at) +
+
Tempoh Check-In
+
{{ $program->checkin_start_at->format('d/m H:i') }} — {{ $program->checkin_end_at?->format('d/m H:i') ?? '—' }}
+
+ @endif + @if($program->ecert_download_start_at) +
+
Mula Download Sijil
+
{{ $program->ecert_download_start_at->format('d M Y, H:i') }}
+
+ @endif +
+
+
+
+ + {{-- Stat Cards --}} +
+
+
+
+
{{ $stats['total_attendances'] }}
+
Hadir
+
+
+
+
+
{{ $stats['total_participants'] }}
+
Peserta
+
+
+
+
+
{{ $stats['total_certificates'] }}
+
Sijil
+
+
+
+
+
{{ $stats['generated_certificates'] }}
+
Dijana
+
+
+
+
+
+ +{{-- Tab Navigation --}} + + +
+ + {{-- Tab: Peserta --}} +
+
+
+ + {{ $stats['pre_registered'] }} pra-daftar · + {{ $stats['walk_in'] }} walk-in + +
+ +
+ +
+ + {{-- Tab: QR Code --}} +
+ @if($program->qrCode) +
+ QR Code +
+ + Muat Turun PNG + +
+ @csrf + +
+
+
+ URL Check-in:
+ {{ route('public.checkin.show', $program->qrCode->token) }} +
+
+ @else +
+ +

QR Code belum dijana untuk program ini.

+
+ @csrf + +
+
+ @endif +
+ + {{-- Tab: Template Sijil --}} +
+ @if($program->certificateTemplate) +
+ Template aktif: {{ $program->certificateTemplate->original_filename }} + + Urus Template + +
+
+ Template +
+ @else +
+ +

Belum ada template sijil untuk program ini.

+ + Upload Template + +
+ @endif +
+ + {{-- Tab: Soalselidik --}} +
+ @if($program->questionnaire && $program->questionnaire->questionnaireSet) + @php $qs = $program->questionnaire->questionnaireSet; @endphp +
+
+ {{ $qs->title }} + @if($program->questionnaire->is_confirmed) + Disahkan + @else + Belum Disahkan + @endif +
+ + Urus Soalselidik + +
+

{{ $qs->questions->count() }} soalan · {{ $qs->description }}

+ @else +
+ +

Belum ada soalselidik dilampirkan untuk program ini.

+ + Lampir Soalselidik + +
+ @endif +
+ + {{-- Tab: Statistik --}} +
+ +
+ +
+ +@endsection + +@push('scripts') + +@endpush