refactor: susun semula struktur folder — Laravel source ke src/

This commit is contained in:
Saufi
2026-05-19 15:58:35 +08:00
parent f052251b94
commit bf53c71b45
10806 changed files with 1385379 additions and 121 deletions

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\GenerateCertificateJob;
use App\Models\Certificate;
use App\Models\Program;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CertificateController extends Controller
{
public function index(Program $program): View
{
$certificates = $program->certificates()
->with('participant')
->latest()
->paginate(50);
$stats = [
'total' => $program->certificates()->count(),
'generated' => $program->certificates()->whereIn('status', ['generated', 'emailed', 'downloaded'])->count(),
'pending' => $program->certificates()->where('status', 'pending')->count(),
'failed' => $program->certificates()->where('status', 'failed')->count(),
'emailed' => $program->certificates()->where('status', 'emailed')->count(),
];
return view('admin.programs.certificates.index', compact('program', 'certificates', 'stats'));
}
public function generateAll(Request $request, Program $program): RedirectResponse
{
$template = $program->certificateTemplate;
if (! $template) {
return back()->with('error', 'Template sijil belum ditetapkan untuk program ini.');
}
// Find all attended participants without certificates
$existingIds = $program->certificates()->pluck('participant_id')->toArray();
$attended = $program->attendances()
->whereNotIn('participant_id', $existingIds)
->get();
if ($attended->isEmpty() && $program->certificates()->count() === 0) {
return back()->with('error', 'Tiada peserta yang hadir untuk dijana sijil.');
}
$sequence = $program->certificates()->count();
$created = 0;
foreach ($attended as $attendance) {
$sequence++;
$cert = Certificate::firstOrCreate(
['program_id' => $program->id, 'participant_id' => $attendance->participant_id],
[
'certificate_template_id' => $template->id,
'certificate_no' => $this->buildCertNo($program, $sequence),
'status' => 'pending',
]
);
if ($cert->wasRecentlyCreated || $cert->status === 'failed') {
$cert->update(['certificate_template_id' => $template->id, 'status' => 'pending']);
GenerateCertificateJob::dispatch($cert);
$created++;
}
}
// Re-queue failed certificates
$failed = $program->certificates()->where('status', 'failed')->get();
foreach ($failed as $cert) {
$cert->update(['status' => 'pending', 'error_message' => null]);
GenerateCertificateJob::dispatch($cert);
$created++;
}
return back()->with('success', "Penjanaan sijil telah diantri untuk {$created} peserta.");
}
public function emailAll(Program $program): RedirectResponse
{
$toEmail = $program->certificates()
->whereIn('status', ['generated'])
->whereNull('emailed_at')
->count();
if ($toEmail === 0) {
return back()->with('error', 'Tiada sijil yang sedia untuk dihantar emel.');
}
// Dispatch email blast job — implemented in Fasa 8
\App\Jobs\SendCertificateEmailJob::dispatchBatch($program);
return back()->with('success', "Penghantaran emel sijil dijadualkan untuk {$toEmail} peserta.");
}
private function buildCertNo(Program $program, int $seq): string
{
$year = now()->format('Y');
$prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $program->title), 0, 4));
return sprintf('%s/%s/%04d', $prefix ?: 'ECT', $year, $seq);
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\CertificateTemplate;
use App\Models\Program;
use App\Services\CertificateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class CertificateTemplateController extends Controller
{
public function show(Program $program): View
{
$template = $program->certificateTemplate;
return view('admin.programs.template.show', compact('program', 'template'));
}
public function store(Request $request, Program $program): RedirectResponse
{
$request->validate([
'template_image' => 'required|image|mimes:jpg,jpeg,png|max:10240',
]);
$file = $request->file('template_image');
$filename = $file->getClientOriginalName();
$path = $file->store('templates/' . $program->id, 'local');
// Deactivate previous templates
$program->certificateTemplates()->update(['is_active' => false]);
$program->certificateTemplates()->create([
'original_filename' => $filename,
'image_path' => $path,
'is_active' => true,
'uploaded_by' => auth()->id(),
'config_json' => $this->defaultConfig($file->getPath() . '/' . $file->getFilename()),
]);
return redirect()->route('admin.programs.template.show', $program)
->with('success', 'Template sijil berjaya dimuat naik. Konfigurasi kedudukan teks di bawah.');
}
public function updateConfig(Request $request, Program $program): RedirectResponse
{
$template = $program->certificateTemplate;
abort_if(! $template, 404);
$request->validate([
'fields' => 'required|array',
'fields.*.x' => 'required|integer|min:0',
'fields.*.y' => 'required|integer|min:0',
'fields.*.font_size' => 'required|integer|min:8|max:200',
'fields.*.ic_font_size' => 'nullable|integer|min:8|max:200',
'fields.*.font_color' => 'required|string|max:20',
'fields.*.align' => 'required|in:left,center,right',
]);
$config = $template->config_json ?? [];
$config['fields'] = array_merge($config['fields'] ?? [], $request->fields);
$template->update(['config_json' => $config]);
return redirect()->route('admin.programs.template.show', $program)
->with('success', 'Konfigurasi template berjaya dikemaskini.');
}
public function destroy(Program $program): RedirectResponse
{
$template = $program->certificateTemplate;
abort_if(! $template, 404);
Storage::disk('local')->delete($template->image_path);
$template->delete();
return redirect()->route('admin.programs.template.show', $program)
->with('success', 'Template sijil dipadam.');
}
public function preview(Program $program): Response
{
$template = $program->certificateTemplate;
abort_if(! $template, 404);
$content = Storage::disk('local')->get($template->image_path);
return response($content, 200, [
'Content-Type' => 'image/jpeg',
'Content-Disposition' => 'inline',
'Cache-Control' => 'private, max-age=3600',
]);
}
public function testGenerate(Request $request, Program $program, CertificateService $service): \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
{
$template = $program->certificateTemplate;
abort_if(! $template, 404);
$sampleName = $request->input('sample_name', 'NAMA PESERTA CONTOH');
$sampleNo = $request->input('sample_no', 'ECT/2025/0001');
// Bina override dari nilai form semasa (belum disimpan)
// Gabung dengan config tersimpan supaya font_file & valign kekal
$liveFields = null;
if ($request->has('fields') && is_array($request->input('fields'))) {
$saved = $template->config_json['fields'] ?? [];
$liveFields = [];
foreach ($request->input('fields') as $key => $cfg) {
$liveFields[$key] = array_merge($saved[$key] ?? [], array_filter($cfg, fn($v) => $v !== null && $v !== ''));
}
}
try {
$imageData = $service->generatePreview($template, $sampleName, $sampleNo, $liveFields);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
return response($imageData, 200, [
'Content-Type' => 'image/jpeg',
'Content-Disposition' => 'inline; filename="preview.jpg"',
]);
}
private function defaultConfig(string $imagePath): array
{
[$width, $height] = getimagesize($imagePath) + [0, 0, 0, 0];
$cx = (int) round(($width ?: 1600) / 2);
$cy = (int) round(($height ?: 1100) * 0.52);
return [
'width' => $width ?: 1600,
'height' => $height ?: 1100,
'fields' => [
'name' => [
'x' => $cx,
'y' => $cy,
'font_size' => 52,
'font_color' => '#1a3a6b',
'font_file' => 'DejaVuSans-Bold.ttf',
'align' => 'center',
'valign' => 'top',
],
],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use App\Models\Certificate;
use App\Models\EmailLog;
use App\Models\Participant;
use App\Models\Program;
use App\Models\QuestionnaireResponse;
class DashboardController extends Controller
{
public function index()
{
$stats = [
'total_programs' => Program::count(),
'active_programs' => Program::where('status', 'published')->count(),
'total_participants' => Participant::count(),
'total_attendances' => Attendance::count(),
'total_certificates' => Certificate::count(),
'generated_certs' => Certificate::whereIn('status', ['generated', 'emailed', 'downloaded'])->count(),
'downloaded_certs' => Certificate::where('status', 'downloaded')->count(),
'total_responses' => QuestionnaireResponse::count(),
'pending_emails' => EmailLog::where('status', 'pending')->count(),
];
$recentPrograms = Program::with('creator')
->latest()
->limit(5)
->get();
return view('admin.dashboard', compact('stats', 'recentPrograms'));
}
}

View File

@@ -0,0 +1,185 @@
<?php
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();
$countRow = DB::table('program_participants')
->where('program_id', $program->id)
->selectRaw("COUNT(*) as total, SUM(is_pre_registered) as pre_registered, SUM(registration_source = 'walk_in') as walk_in, SUM(status = 'checked_in') as checked_in")
->first();
$counts = [
'total' => (int) ($countRow->total ?? 0),
'pre_registered' => (int) ($countRow->pre_registered ?? 0),
'walk_in' => (int) ($countRow->walk_in ?? 0),
'checked_in' => (int) ($countRow->checked_in ?? 0),
];
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);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class ProfileController extends Controller
{
public function show(): View
{
return view('admin.profile.show', ['user' => auth()->user()]);
}
public function updateEmail(Request $request): RedirectResponse
{
$validator = \Validator::make($request->all(), [
'current_password' => ['required', 'current_password'],
'email' => ['required', 'email', 'max:255', 'unique:users,email,' . auth()->id()],
], [
'current_password.current_password' => 'Kata laluan semasa tidak betul.',
'email.unique' => 'Alamat emel ini sudah digunakan.',
]);
if ($validator->fails()) {
return back()->withErrors($validator, 'email')->withInput();
}
auth()->user()->update(['email' => $request->email]);
return back()->with('email_success', 'Alamat emel berjaya dikemaskini.');
}
public function updatePassword(Request $request): RedirectResponse
{
$validator = \Validator::make($request->all(), [
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::min(8)],
], [
'current_password.current_password' => 'Kata laluan semasa tidak betul.',
'password.min' => 'Kata laluan baru mestilah sekurang-kurangnya 8 aksara.',
'password.confirmed' => 'Pengesahan kata laluan tidak sepadan.',
]);
if ($validator->fails()) {
return back()->withErrors($validator, 'password')->withInput();
}
auth()->user()->update(['password' => Hash::make($request->password)]);
Auth::login(auth()->user());
return back()->with('password_success', 'Kata laluan berjaya ditukar.');
}
}

View File

@@ -0,0 +1,165 @@
<?php
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
{
public function index(Request $request): View
{
$query = Program::with('creator')
->withCount(['attendances', 'programParticipants'])
->latest();
// Admin program hanya nampak program sendiri
if (auth()->user()->isAdminProgram()) {
$query->where('created_by', auth()->id());
}
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'));
}
public function create(): View
{
return view('admin.programs.create');
}
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.');
}
public function show(Program $program): View
{
$this->authorize('view', $program);
$program->load([
'qrCode',
'certificateTemplate',
'questionnaire.questionnaireSet.questions',
]);
// Consolidate into 2 queries instead of 6 separate COUNTs
$ppStats = \DB::table('program_participants')
->where('program_id', $program->id)
->selectRaw("COUNT(*) as total, SUM(is_pre_registered) as pre_registered, SUM(registration_source = 'walk_in') as walk_in")
->first();
$certStats = \DB::table('certificates')
->where('program_id', $program->id)
->selectRaw("COUNT(*) as total, SUM(status IN ('generated','emailed','downloaded')) as cert_generated")
->first();
$stats = [
'total_participants' => (int) ($ppStats->total ?? 0),
'pre_registered' => (int) ($ppStats->pre_registered ?? 0),
'walk_in' => (int) ($ppStats->walk_in ?? 0),
'total_attendances' => $program->attendances()->count(),
'total_certificates' => (int) ($certStats->total ?? 0),
'generated_certificates' => (int) ($certStats->cert_generated ?? 0),
];
return view('admin.programs.show', compact('program', 'stats'));
}
public function edit(Program $program): View
{
$this->authorize('update', $program);
return view('admin.programs.edit', compact('program'));
}
public function update(UpdateProgramRequest $request, Program $program): RedirectResponse
{
$this->authorize('update', $program);
$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.');
}
public function destroy(Program $program): RedirectResponse
{
$this->authorize('delete', $program);
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
{
$this->authorize('update', $program);
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
{
$this->authorize('update', $program);
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.');
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Program;
use App\Models\ProgramQuestionnaire;
use App\Models\QuestionnaireSet;
use App\Services\AuditLogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProgramQuestionnaireController extends Controller
{
public function show(Program $program): View
{
$pq = $program->questionnaire()->with('questionnaireSet.questions')->first();
$availableSets = QuestionnaireSet::where('status', 'published')
->withCount('questions')
->orderBy('title')
->get();
return view('admin.programs.questionnaire.show', compact('program', 'pq', 'availableSets'));
}
public function attach(Request $request, Program $program): RedirectResponse
{
$data = $request->validate([
'questionnaire_set_id' => 'required|exists:questionnaire_sets,id',
]);
if ($program->questionnaire()->exists()) {
return back()->with('error', 'Program ini sudah ada soalselidik dilampirkan. Tanggalkan dahulu sebelum lampir yang baru.');
}
$set = QuestionnaireSet::findOrFail($data['questionnaire_set_id']);
if ($set->status !== 'published') {
return back()->with('error', 'Hanya soalselidik yang diterbitkan boleh dilampirkan.');
}
ProgramQuestionnaire::create([
'program_id' => $program->id,
'questionnaire_set_id' => $set->id,
'is_confirmed' => false,
]);
return back()->with('success', 'Soalselidik berjaya dilampirkan. Sila sahkan sebelum program bermula.');
}
public function confirm(Request $request, Program $program): RedirectResponse
{
$pq = $program->questionnaire;
if (! $pq) {
return back()->with('error', 'Tiada soalselidik untuk disahkan.');
}
$pq->update([
'is_confirmed' => true,
'confirmed_at' => now(),
'confirmed_by' => auth()->id(),
]);
AuditLogService::log('questionnaire.confirmed', $pq, [], ['program_id' => $program->id, 'questionnaire_set_id' => $pq->questionnaire_set_id]);
return back()->with('success', 'Soalselidik telah disahkan untuk program ini.');
}
public function preview(Program $program): View|\Illuminate\Http\RedirectResponse
{
$pq = $program->questionnaire()->with('questionnaireSet')->first();
if (! $pq || ! $pq->questionnaireSet) {
return back()->with('error', 'Tiada soalselidik untuk dipratonton.');
}
$questions = $pq->questionnaireSet->questions()
->whereNull('parent_id')
->with(['children' => fn($q) => $q->orderBy('sort_order')])
->orderBy('sort_order')
->get();
return view('admin.programs.questionnaire.preview', compact('program', 'pq', 'questions'));
}
public function detach(Program $program): RedirectResponse
{
$pq = $program->questionnaire;
if (! $pq) {
return back()->with('error', 'Tiada soalselidik untuk ditanggalkan.');
}
if ($pq->is_confirmed) {
$hasResponses = \App\Models\QuestionnaireResponse::where('program_id', $program->id)->exists();
if ($hasResponses) {
return back()->with('error', 'Soalselidik tidak boleh ditanggalkan kerana sudah ada respons diterima.');
}
}
$pq->delete();
return back()->with('success', 'Soalselidik berjaya ditanggalkan.');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Program;
use App\Services\AuditLogService;
use App\Services\QrCodeService;
use Illuminate\Support\Str;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\View\View;
class QrCodeController extends Controller
{
public function __construct(private QrCodeService $qrCodeService) {}
public function show(Program $program): View
{
$qrCode = $program->qrCodes()->where('is_active', true)->latest()->first();
return view('admin.programs.qr', compact('program', 'qrCode'));
}
public function generate(Program $program): RedirectResponse
{
$qrCode = $this->qrCodeService->generateForProgram($program);
AuditLogService::log('qrcode.generated', $program);
return redirect()
->route('admin.programs.qr.show', $program)
->with('success', 'QR Code berjaya dijana.');
}
public function download(Program $program): Response|RedirectResponse
{
$qrCode = $program->qrCodes()->where('is_active', true)->latest()->first();
if (! $qrCode) {
return back()->with('error', 'QR Code belum dijana.');
}
$png = $this->qrCodeService->getRawPng($qrCode);
$filename = 'QR_' . Str::slug($program->title) . '_' . now()->format('Ymd') . '.png';
return response($png, 200, [
'Content-Type' => 'image/png',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
public function deactivate(Program $program): RedirectResponse
{
$program->qrCodes()->where('is_active', true)->update(['is_active' => false]);
AuditLogService::log('qrcode.deactivated', $program);
return back()->with('success', 'QR Code berjaya dinyahaktifkan.');
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\QuestionnaireQuestion;
use App\Models\QuestionnaireSet;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class QuestionController extends Controller
{
public function store(Request $request, QuestionnaireSet $set): RedirectResponse
{
if ($request->has('options')) {
$request->merge(['options' => array_values(array_filter($request->input('options', [])))]);
}
$data = $request->validate([
'question_text' => 'required|string|max:1000',
'question_type' => 'required|in:tajuk,rating,single_choice,multiple_choice,short_text,long_text',
'is_required' => 'boolean',
'parent_id' => 'nullable|integer|exists:questionnaire_questions,id',
'options' => 'nullable|array',
'options.*' => 'required|string|max:255',
'rating_labels' => 'nullable|array',
'rating_labels.*' => 'nullable|string|max:100',
]);
if ($data['question_type'] === 'rating') {
if (empty($data['parent_id'])) {
return back()->withErrors(['parent_id' => 'Soalan rating mesti diletakkan di bawah tajuk.'])->withInput();
}
$parent = QuestionnaireQuestion::find($data['parent_id']);
if (! $parent || $parent->question_type !== 'tajuk') {
return back()->withErrors(['parent_id' => 'Parent mesti jenis Tajuk.'])->withInput();
}
}
$needsOptions = in_array($data['question_type'], ['single_choice', 'multiple_choice']);
if ($needsOptions && empty($data['options'])) {
return back()->withErrors(['options' => 'Pilihan jawapan diperlukan untuk jenis soalan ini.'])->withInput();
}
$parentId = $data['question_type'] === 'rating' ? ($data['parent_id'] ?? null) : null;
$maxOrder = $set->questions()
->when($parentId,
fn($q) => $q->where('parent_id', $parentId),
fn($q) => $q->whereNull('parent_id')
)
->max('sort_order') ?? 0;
$ratingLabels = null;
if ($data['question_type'] === 'tajuk') {
$filtered = array_filter($data['rating_labels'] ?? [], fn($v) => $v !== null && $v !== '');
$ratingLabels = ! empty($filtered) ? $filtered : null;
}
$set->questions()->create([
'question_text' => $data['question_text'],
'question_type' => $data['question_type'],
'parent_id' => $parentId,
'is_required' => $data['question_type'] === 'tajuk' ? false : ($data['is_required'] ?? true),
'options_json' => $needsOptions ? array_values(array_filter($data['options'] ?? [])) : null,
'rating_labels' => $ratingLabels,
'sort_order' => $maxOrder + 1,
]);
return redirect()->route('admin.questionnaires.show', $set)
->with('success', 'Soalan berjaya ditambah.');
}
public function update(Request $request, QuestionnaireQuestion $question): RedirectResponse
{
if ($request->has('options')) {
$request->merge(['options' => array_values(array_filter($request->input('options', [])))]);
}
$data = $request->validate([
'question_text' => 'required|string|max:1000',
'question_type' => 'required|in:tajuk,rating,single_choice,multiple_choice,short_text,long_text',
'is_required' => 'boolean',
'parent_id' => 'nullable|integer|exists:questionnaire_questions,id',
'options' => 'nullable|array',
'options.*' => 'required|string|max:255',
'rating_labels' => 'nullable|array',
'rating_labels.*' => 'nullable|string|max:100',
]);
if ($data['question_type'] === 'rating') {
if (empty($data['parent_id'])) {
return back()->withErrors(['parent_id' => 'Soalan rating mesti diletakkan di bawah tajuk.'])->withInput();
}
$parent = QuestionnaireQuestion::find($data['parent_id']);
if (! $parent || $parent->question_type !== 'tajuk') {
return back()->withErrors(['parent_id' => 'Parent mesti jenis Tajuk.'])->withInput();
}
}
$needsOptions = in_array($data['question_type'], ['single_choice', 'multiple_choice']);
$parentId = $data['question_type'] === 'rating' ? ($data['parent_id'] ?? null) : null;
$ratingLabels = null;
if ($data['question_type'] === 'tajuk') {
$filtered = array_filter($data['rating_labels'] ?? [], fn($v) => $v !== null && $v !== '');
$ratingLabels = ! empty($filtered) ? $filtered : null;
}
$question->update([
'question_text' => $data['question_text'],
'question_type' => $data['question_type'],
'parent_id' => $parentId,
'is_required' => $data['question_type'] === 'tajuk' ? false : ($data['is_required'] ?? true),
'options_json' => $needsOptions ? array_values(array_filter($data['options'] ?? [])) : null,
'rating_labels' => $ratingLabels,
]);
return redirect()->route('admin.questionnaires.show', $question->questionnaire_set_id)
->with('success', 'Soalan berjaya dikemaskini.');
}
public function destroy(QuestionnaireQuestion $question): RedirectResponse
{
$setId = $question->questionnaire_set_id;
// Cascade-delete children if this is a tajuk (DB cascade handles it too, but be explicit)
if ($question->question_type === 'tajuk') {
$question->children()->delete();
}
$question->delete();
return redirect()->route('admin.questionnaires.show', $setId)
->with('success', 'Soalan berjaya dipadam.');
}
public function reorder(Request $request): JsonResponse
{
$data = $request->validate([
'order' => 'required|array',
'order.*' => 'integer|exists:questionnaire_questions,id',
'parent_id' => 'nullable|integer|exists:questionnaire_questions,id',
]);
foreach ($data['order'] as $sortOrder => $questionId) {
QuestionnaireQuestion::where('id', $questionId)
->update(['sort_order' => $sortOrder + 1]);
}
return response()->json(['ok' => true]);
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\QuestionnaireSet;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class QuestionnaireSetController extends Controller
{
public function index(Request $request): View
{
$query = QuestionnaireSet::withCount('questions')
->with('creator')
->latest();
if ($request->filled('q')) {
$query->where('title', 'like', '%' . $request->q . '%');
}
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$sets = $query->paginate(20)->withQueryString();
return view('admin.questionnaires.index', compact('sets'));
}
public function create(): View
{
return view('admin.questionnaires.create');
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
]);
$data['created_by'] = auth()->id();
$data['status'] = 'draft';
$set = QuestionnaireSet::create($data);
return redirect()->route('admin.questionnaires.show', $set)
->with('success', 'Set soalselidik berjaya dicipta.');
}
public function show(QuestionnaireSet $set): View
{
$set->load('creator');
$topLevel = $set->questions()
->whereNull('parent_id')
->with(['children' => fn($q) => $q->orderBy('sort_order')])
->orderBy('sort_order')
->get();
$totalCount = $set->questions()->count();
$usedInPrograms = $set->programs()->get();
return view('admin.questionnaires.show', compact('set', 'topLevel', 'totalCount', 'usedInPrograms'));
}
public function edit(QuestionnaireSet $set): View
{
return view('admin.questionnaires.edit', compact('set'));
}
public function update(Request $request, QuestionnaireSet $set): RedirectResponse
{
$data = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
]);
$set->update($data);
return redirect()->route('admin.questionnaires.show', $set)
->with('success', 'Set soalselidik berjaya dikemaskini.');
}
public function destroy(QuestionnaireSet $set): RedirectResponse
{
$confirmed = $set->programQuestionnaires()->where('is_confirmed', true)->exists();
if ($confirmed) {
return back()->with('error', 'Soalselidik ini tidak boleh dipadam kerana sudah disahkan untuk program.');
}
$set->delete();
return redirect()->route('admin.questionnaires.index')
->with('success', 'Set soalselidik berjaya dipadam.');
}
public function publish(QuestionnaireSet $set): RedirectResponse
{
if ($set->questions()->count() === 0) {
return back()->with('error', 'Tambah sekurang-kurangnya satu soalan sebelum menerbitkan.');
}
$set->update(['status' => 'published']);
return back()->with('success', 'Set soalselidik diterbitkan.');
}
public function archive(QuestionnaireSet $set): RedirectResponse
{
$set->update(['status' => 'archived']);
return back()->with('success', 'Set soalselidik diarkibkan.');
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Program;
use App\Models\Attendance;
use App\Models\Certificate;
use App\Models\QuestionnaireAnswer;
use App\Models\QuestionnaireQuestion;
use App\Models\QuestionnaireResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class StatisticsController extends Controller
{
public function show(Program $program): View
{
$program->load(['questionnaire.questionnaireSet.questions']);
// Attendance by session
$bySession = $program->attendances()
->selectRaw('attendance_session, COUNT(*) as total')
->groupBy('attendance_session')
->pluck('total', 'attendance_session')
->toArray();
// Attendance by source
$bySource = $program->attendances()
->selectRaw('attendance_source, COUNT(*) as total')
->groupBy('attendance_source')
->pluck('total', 'attendance_source')
->toArray();
// Certificate status breakdown
$certStats = $program->certificates()
->selectRaw('status, COUNT(*) as total')
->groupBy('status')
->pluck('total', 'status')
->toArray();
// Response rate + question stats
$pq = $program->questionnaire;
$responseRate = null;
$questionStats = [];
$totalResponses = 0;
if ($pq && $pq->is_confirmed) {
$totalAttended = array_sum($bySession); // reuse already-fetched data
$totalResponses = QuestionnaireResponse::where('program_id', $program->id)->count();
$responseRate = $totalAttended > 0 ? round($totalResponses / $totalAttended * 100) : 0;
$questions = $pq->questionnaireSet->questions ?? collect();
// Load ALL answers in one query, group by question — avoids N+1
$allAnswers = QuestionnaireAnswer::whereIn('questionnaire_question_id', $questions->pluck('id'))
->get()
->groupBy('questionnaire_question_id');
foreach ($questions as $q) {
$answers = $allAnswers->get($q->id, collect());
if ($q->question_type === 'rating') {
$values = $answers->map(fn($a) => is_array($a->answer_value) ? (int) ($a->answer_value[0] ?? 0) : (int) $a->answer_value);
$questionStats[] = [
'id' => $q->id,
'text' => $q->question_text,
'type' => 'rating',
'average' => $values->count() > 0 ? round($values->avg(), 2) : null,
'count' => $values->count(),
];
} elseif (in_array($q->question_type, ['single_choice', 'multiple_choice'])) {
$counts = [];
foreach ($answers as $row) {
$items = is_array($row->answer_value) ? $row->answer_value : [$row->answer_value];
foreach ($items as $item) {
$counts[$item] = ($counts[$item] ?? 0) + 1;
}
}
$questionStats[] = [
'id' => $q->id,
'text' => $q->question_text,
'type' => $q->question_type,
'options' => $q->options_json ?? [],
'counts' => $counts,
'total' => $answers->count(),
];
}
}
}
// Reuse data already computed above — no extra queries
$summary = [
'total_attendances' => array_sum($bySession),
'pre_registered' => $bySource['pre_registered_staff'] ?? 0,
'walk_in' => $bySource['walk_in_external'] ?? 0,
'total_certificates' => array_sum($certStats),
'generated_certs' => ($certStats['generated'] ?? 0) + ($certStats['emailed'] ?? 0) + ($certStats['downloaded'] ?? 0),
'downloaded_certs' => $certStats['downloaded'] ?? 0,
'total_responses' => $totalResponses,
];
return view('admin.programs.statistics.show', compact(
'program', 'summary', 'bySession', 'bySource',
'certStats', 'responseRate', 'questionStats'
));
}
public function export(Program $program): Response
{
$rows = $program->attendances()
->with('participant')
->get()
->map(fn($a) => [
$a->participant->name,
$a->participant->agency ?: '',
$a->attendance_session,
$a->attendance_source,
$a->checked_in_at->format('d/m/Y H:i'),
]);
$csv = "\xEF\xBB\xBF";
$csv .= implode(',', ['Nama', 'Agensi', 'Sesi', 'Sumber', 'Masa Check-In']) . "\n";
foreach ($rows as $row) {
$csv .= implode(',', array_map(fn($v) => '"' . str_replace('"', '""', $v) . '"', $row)) . "\n";
}
$filename = 'statistik-' . str($program->title)->slug() . '-' . now()->format('Ymd') . '.csv';
return response($csv, 200, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\AuditLogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class UserController extends Controller
{
public function index(): View
{
$users = User::withCount('programs')->latest()->paginate(20);
return view('admin.users.index', compact('users'));
}
public function create(): View
{
return view('admin.users.create');
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email',
'role' => 'required|in:super_admin,admin',
'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
]);
$user = User::create($data);
AuditLogService::log('user.created', $user, [], ['email' => $user->email, 'role' => $user->role]);
return redirect()->route('admin.users.index')
->with('success', 'Pengguna "' . $user->name . '" berjaya ditambah.');
}
public function edit(User $user): View
{
return view('admin.users.edit', compact('user'));
}
public function update(Request $request, User $user): RedirectResponse
{
$rules = [
'name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email,' . $user->id,
'role' => 'required|in:super_admin,admin',
];
if ($request->filled('password')) {
$rules['password'] = ['confirmed', Password::min(8)->mixedCase()->numbers()];
}
$data = $request->validate($rules);
if (! $request->filled('password')) {
unset($data['password']);
}
$old = $user->only(['name', 'email', 'role']);
$user->update($data);
AuditLogService::log('user.updated', $user, $old, $user->only(['name', 'email', 'role']));
return redirect()->route('admin.users.index')
->with('success', 'Maklumat pengguna "' . $user->name . '" berjaya dikemas kini.');
}
public function destroy(User $user): RedirectResponse
{
if ($user->id === auth()->id()) {
return back()->with('error', 'Anda tidak boleh padam akaun sendiri.');
}
$name = $user->name;
AuditLogService::log('user.deleted', $user);
$user->delete();
return redirect()->route('admin.users.index')
->with('success', 'Pengguna "' . $name . '" berjaya dipadam.');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('admin.dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('admin.dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('admin.dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('admin.dashboard', absolute: false))
: view('auth.verify-email');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('admin.dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('admin.dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('admin.dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\Certificate;
use App\Models\Participant;
use App\Models\ProgramQrCode;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AttendanceCheckController extends Controller
{
public function show(string $qr_token): View
{
$qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail();
$program = $qrCode->program;
abort_if($program->status !== 'published', 404);
return view('public.semak.show', compact('program', 'qrCode'));
}
public function check(string $qr_token, Request $request): View
{
$qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail();
$program = $qrCode->program;
$request->validate([
'no_kp' => ['required', 'digits:12'],
], [
'no_kp.required' => 'Sila masukkan No. Kad Pengenalan anda.',
'no_kp.digits' => 'No. Kad Pengenalan mestilah 12 digit tanpa sempang.',
]);
$noKp = preg_replace('/[^0-9]/', '', $request->no_kp);
$participant = Participant::where('no_kp', $noKp)->first();
if (! $participant) {
return view('public.semak.result', [
'program' => $program,
'qrCode' => $qrCode,
'found' => false,
'participant' => null,
'attendance' => null,
'certificate' => null,
]);
}
$attendance = $participant->attendanceForProgram($program->id);
$certificate = $attendance
? Certificate::where('program_id', $program->id)
->where('participant_id', $participant->id)
->first()
: null;
return view('public.semak.result', compact('program', 'qrCode', 'participant', 'attendance', 'certificate'))
->with('found', (bool) $attendance);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\Certificate;
use App\Models\QuestionnaireResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class CertificateController extends Controller
{
public function show(string $cert_token): View|RedirectResponse
{
$certificate = Certificate::where('token', $cert_token)
->with(['participant', 'program', 'template'])
->firstOrFail();
$program = $certificate->program;
$participant = $certificate->participant;
// Check questionnaire gate
$pq = $program->questionnaire()->first();
$needsQuestionnaire = $pq && $pq->is_confirmed;
$hasAnswered = false;
if ($needsQuestionnaire) {
$hasAnswered = QuestionnaireResponse::where('program_id', $program->id)
->where('participant_id', $participant->id)
->exists();
}
// Find active QR code for questionnaire redirect
$qrCode = $program->qrCode;
return view('public.certificate.show', compact(
'certificate', 'program', 'participant', 'pq',
'needsQuestionnaire', 'hasAnswered', 'qrCode'
));
}
public function download(string $cert_token): Response|RedirectResponse
{
$certificate = Certificate::where('token', $cert_token)
->with(['participant', 'program'])
->firstOrFail();
if (! $certificate->isGenerated()) {
return back()->with('error', 'Sijil belum sedia untuk dimuat turun.');
}
if (! $certificate->file_path || ! Storage::disk('local')->exists($certificate->file_path)) {
return back()->with('error', 'Fail sijil tidak dijumpai. Sila hubungi penganjur.');
}
// Check questionnaire gate
$program = $certificate->program;
$pq = $program->questionnaire()->first();
if ($pq && $pq->is_confirmed) {
$hasAnswered = QuestionnaireResponse::where('program_id', $program->id)
->where('participant_id', $certificate->participant_id)
->exists();
if (! $hasAnswered) {
$qrCode = $program->qrCode;
$redirectTo = $qrCode
? route('public.questionnaire.show', [$qrCode->token, $certificate->participant->uuid])
: back();
return redirect($redirectTo)->with('info', 'Sila jawab borang penilaian terlebih dahulu.');
}
}
$certificate->recordDownload();
$content = Storage::disk('local')->get($certificate->file_path);
$filename = 'Sijil-' . str($certificate->participant->name)->slug() . '.jpg';
return response($content, 200, [
'Content-Type' => 'image/jpeg',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => strlen($content),
]);
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\ProgramQrCode;
use App\Services\AttendanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CheckinController extends Controller
{
public function __construct(private AttendanceService $attendanceService) {}
public function show(string $qr_token): View|RedirectResponse
{
$qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail();
$program = $qrCode->program;
if ($program->status !== 'published') {
return view('public.checkin.unavailable', compact('program', 'qrCode'))
->with('message', 'Program ini belum dibuka atau sudah ditutup.');
}
// If download period is active → redirect to attendance check page
if ($program->isDownloadOpen()) {
return redirect()->route('public.semak.show', $qr_token);
}
// Check-in not yet open
if ($program->checkin_start_at && now()->lt($program->checkin_start_at)) {
return view('public.checkin.unavailable', compact('program', 'qrCode'))
->with('message', 'Check-in belum dibuka. Mula pada ' . $program->checkin_start_at->format('d M Y, H:i'));
}
// Check-in already closed
if ($program->checkin_end_at && now()->gt($program->checkin_end_at)) {
return view('public.checkin.unavailable', compact('program', 'qrCode'))
->with('message', 'Tempoh check-in telah tamat.');
}
return view('public.checkin.show', compact('program', 'qrCode'));
}
public function staffCheckin(string $qr_token, Request $request): View|RedirectResponse
{
$qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail();
$program = $qrCode->program;
$request->validate([
'no_kp' => ['required', 'string', 'max:20'],
], [
'no_kp.required' => 'Sila masukkan No. Kad Pengenalan anda.',
]);
$result = $this->attendanceService->staffCheckin($program, $request->no_kp, $request);
return match ($result['status']) {
'success' => view('public.checkin.success', [
'program' => $program,
'participant' => $result['participant'],
'attendance' => $result['attendance'],
'qrCode' => $qrCode,
]),
'already_checked_in' => view('public.checkin.already', [
'program' => $program,
'participant' => $result['participant'],
'attendance' => $result['attendance'],
]),
'not_found' => back()->withInput()
->with('error', 'No. Kad Pengenalan tidak dijumpai dalam senarai peserta program ini.')
->with('show_external_option', $program->allow_walk_in),
'not_registered' => back()->withInput()
->with('error', 'No. Kad Pengenalan tidak dijumpai dalam senarai pra-daftar.')
->with('show_external_option', $program->allow_walk_in),
default => back()->with('error', 'Ralat tidak dijangka. Sila cuba lagi.'),
};
}
public function externalRegister(string $qr_token, Request $request): View|RedirectResponse
{
$qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail();
$program = $qrCode->program;
if (! $program->allow_walk_in) {
return back()->with('error', 'Pendaftaran orang luar tidak dibenarkan untuk program ini.');
}
$request->validate([
'name' => ['required', 'string', 'max:255'],
'no_kp' => ['required', 'digits:12'],
'email' => ['nullable', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
'agency' => ['nullable', 'string', 'max:255'],
], [
'name.required' => 'Sila masukkan nama penuh anda.',
'no_kp.required' => 'Sila masukkan No. Kad Pengenalan anda.',
'no_kp.digits' => 'No. Kad Pengenalan mestilah 12 digit tanpa sempang.',
'email.email' => 'Format emel tidak sah.',
]);
$result = $this->attendanceService->walkInRegister($program, $request->all(), $request);
return match ($result['status']) {
'success' => view('public.checkin.success', [
'program' => $program,
'participant' => $result['participant'],
'attendance' => $result['attendance'],
'qrCode' => $qrCode,
]),
'already_checked_in' => view('public.checkin.already', [
'program' => $program,
'participant' => $result['participant'],
'attendance' => $result['attendance'],
]),
default => back()->withInput()->with('error', 'Ralat sistem. Sila cuba lagi.'),
};
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\Participant;
use App\Models\ProgramQrCode;
use App\Models\QuestionnaireResponse;
use App\Models\QuestionnaireAnswer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\View\View;
class QuestionnaireController extends Controller
{
public function show(string $qr_token, string $participant_uuid): View|RedirectResponse
{
$qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail();
$program = $qrCode->program;
$participant = Participant::where('uuid', $participant_uuid)->firstOrFail();
$pp = $program->programParticipants()->where('participant_id', $participant->id)->first();
abort_if(! $pp, 404);
$pq = $program->questionnaire()->with('questionnaireSet')->first();
if (! $pq || ! $pq->is_confirmed) {
return redirect()->route('public.semak.show', $qr_token);
}
if (QuestionnaireResponse::where('program_id', $program->id)->where('participant_id', $participant->id)->exists()) {
return view('public.questionnaire.already', compact('program', 'participant', 'qrCode'));
}
$questions = $this->loadHierarchical($pq);
return view('public.questionnaire.show', compact('program', 'participant', 'qrCode', 'pq', 'questions'));
}
public function submit(Request $request, string $qr_token, string $participant_uuid): View|RedirectResponse
{
$qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail();
$program = $qrCode->program;
$participant = Participant::where('uuid', $participant_uuid)->firstOrFail();
$pp = $program->programParticipants()->where('participant_id', $participant->id)->first();
abort_if(! $pp, 404);
$pq = $program->questionnaire()->with('questionnaireSet')->first();
abort_if(! $pq || ! $pq->is_confirmed, 404);
if (QuestionnaireResponse::where('program_id', $program->id)->where('participant_id', $participant->id)->exists()) {
return view('public.questionnaire.already', compact('program', 'participant', 'qrCode'));
}
$questions = $this->loadHierarchical($pq);
$answerable = $this->flatten($questions);
$rules = [];
foreach ($answerable as $q) {
if ($q->question_type === 'multiple_choice') {
$rules['q_' . $q->id] = ($q->is_required ? 'required|' : 'nullable|') . 'array';
} else {
$rules['q_' . $q->id] = $q->is_required ? 'required' : 'nullable';
}
}
$request->validate($rules, ['q_*.required' => 'Soalan ini wajib dijawab.']);
$response = QuestionnaireResponse::create([
'program_id' => $program->id,
'participant_id' => $participant->id,
'questionnaire_set_id' => $pq->questionnaire_set_id,
'submitted_at' => now(),
'ip_address' => $request->ip(),
'user_agent' => substr($request->userAgent() ?? '', 0, 500),
]);
foreach ($answerable as $q) {
$raw = $request->input('q_' . $q->id);
if ($raw === null && ! $q->is_required) {
continue;
}
$value = match ($q->question_type) {
'multiple_choice' => (array) $raw,
'rating' => (int) $raw,
default => $raw,
};
QuestionnaireAnswer::create([
'questionnaire_response_id' => $response->id,
'questionnaire_question_id' => $q->id,
'answer_value' => $value,
]);
}
return view('public.questionnaire.thankyou', compact('program', 'participant', 'qrCode'));
}
// ── Helpers ──────────────────────────────────────────────────────────────
private function loadHierarchical($pq): Collection
{
return $pq->questionnaireSet->questions()
->whereNull('parent_id')
->with(['children' => fn($q) => $q->orderBy('sort_order')])
->orderBy('sort_order')
->get();
}
/** Return only answerable (non-tajuk) questions as a flat collection. */
private function flatten(Collection $topLevel): Collection
{
$out = collect();
foreach ($topLevel as $q) {
if ($q->question_type === 'tajuk') {
foreach ($q->children as $child) {
$out->push($child);
}
} else {
$out->push($q);
}
}
return $out;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
$role = $request->user()?->role;
if (! in_array($role, ['super_admin', 'admin'])) {
abort(403, 'Akses ditolak.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureSuperAdmin
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user()?->role !== 'super_admin') {
abort(403, 'Hanya Super Admin boleh mengakses bahagian ini.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class StoreProgramRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->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'),
]);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Http\Requests\Admin;
class UpdateProgramRequest extends StoreProgramRequest
{
// Inherits all rules from StoreProgramRequest.
// Status changes handled via dedicated publish/close actions, not here.
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}