feat: qr code generation
- QrCodeService: generate unique 48-char token, create QR PNG (400x400, error-correction H) - QrCodeController: show, generate, download PNG, deactivate - Admin QR page: preview, copy URL, download, regenerate, deactivate - Existing active QR deactivated on regenerate - Token-based URL (not program ID) for PDPA compliance Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,59 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
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
|
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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
app/Services/QrCodeService.php
Normal file
51
app/Services/QrCodeService.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Program;
|
||||||
|
use App\Models\ProgramQrCode;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
|
|
||||||
|
class QrCodeService
|
||||||
|
{
|
||||||
|
public function generateForProgram(Program $program): ProgramQrCode
|
||||||
|
{
|
||||||
|
// Deactivate existing active QR codes
|
||||||
|
$program->qrCodes()->where('is_active', true)->update(['is_active' => false]);
|
||||||
|
|
||||||
|
$token = Str::random(48);
|
||||||
|
$url = route('public.checkin.show', $token);
|
||||||
|
$path = 'public/qrcodes/' . $token . '.png';
|
||||||
|
$absPath = Storage::path($path);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
Storage::makeDirectory('public/qrcodes');
|
||||||
|
|
||||||
|
// Generate QR code PNG (400×400, with quiet zone)
|
||||||
|
$png = QrCode::format('png')
|
||||||
|
->size(400)
|
||||||
|
->margin(2)
|
||||||
|
->errorCorrection('H')
|
||||||
|
->generate($url);
|
||||||
|
|
||||||
|
Storage::put($path, $png);
|
||||||
|
|
||||||
|
return $program->qrCodes()->create([
|
||||||
|
'token' => $token,
|
||||||
|
'qr_image_path' => $path,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPublicUrl(ProgramQrCode $qrCode): string
|
||||||
|
{
|
||||||
|
return Storage::url($qrCode->qr_image_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRawPng(ProgramQrCode $qrCode): string
|
||||||
|
{
|
||||||
|
return Storage::get($qrCode->qr_image_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
resources/views/admin/programs/qr.blade.php
Normal file
171
resources/views/admin/programs/qr.blade.php
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
@extends('layouts.admin')
|
||||||
|
|
||||||
|
@section('title', 'QR Code — ' . $program->title)
|
||||||
|
@section('header', 'QR Code Program')
|
||||||
|
|
||||||
|
@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, 30) }}</a></li>
|
||||||
|
<li class="breadcrumb-item active">QR Code</li>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
|
||||||
|
<div class="row g-4 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-qr-code me-2 text-primary"></i>QR Code Check-In</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center py-4">
|
||||||
|
|
||||||
|
@if($qrCode)
|
||||||
|
{{-- QR Code Image --}}
|
||||||
|
<div class="mb-4">
|
||||||
|
<img src="{{ Storage::url($qrCode->qr_image_path) }}"
|
||||||
|
alt="QR Code {{ $program->title }}"
|
||||||
|
class="img-fluid border rounded p-2"
|
||||||
|
style="max-width: 280px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Program Info --}}
|
||||||
|
<h6 class="fw-semibold mb-1">{{ $program->title }}</h6>
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
{{ $program->start_date->format('d M Y') }}
|
||||||
|
@if($program->start_date->ne($program->end_date))
|
||||||
|
— {{ $program->end_date->format('d M Y') }}
|
||||||
|
@endif
|
||||||
|
· {{ $program->location }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{-- Check-in URL --}}
|
||||||
|
<div class="bg-light rounded p-3 mb-4 text-start">
|
||||||
|
<div class="text-muted small mb-1">URL Check-In:</div>
|
||||||
|
<code class="small text-break" id="checkinUrl">{{ route('public.checkin.show', $qrCode->token) }}</code>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-2"
|
||||||
|
onclick="copyUrl()" title="Salin URL">
|
||||||
|
<i class="bi bi-clipboard" id="copyIcon"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Action Buttons --}}
|
||||||
|
<div class="d-flex flex-column flex-sm-row gap-2 justify-content-center">
|
||||||
|
<a href="{{ route('admin.programs.qr.download', $program) }}"
|
||||||
|
class="btn btn-primary">
|
||||||
|
<i class="bi bi-download me-2"></i>Muat Turun PNG
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('admin.programs.qr.generate', $program) }}">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="btn btn-outline-warning w-100"
|
||||||
|
onclick="return confirm('Jana semula QR Code? QR Code lama akan dinyahaktifkan.')">
|
||||||
|
<i class="bi bi-arrow-repeat me-2"></i>Jana Semula
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('admin.programs.qr.deactivate', $program) }}">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="btn btn-outline-danger w-100"
|
||||||
|
onclick="return confirm('Nyahaktifkan QR Code ini?')">
|
||||||
|
<i class="bi bi-x-circle me-2"></i>Nyahaktifkan
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Status --}}
|
||||||
|
<div class="mt-3">
|
||||||
|
<span class="badge bg-success"><i class="bi bi-circle-fill me-1" style="font-size:.5rem;"></i>Aktif</span>
|
||||||
|
<small class="text-muted ms-2">Dijana {{ $qrCode->created_at->diffForHumans() }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@else
|
||||||
|
{{-- No QR Code yet --}}
|
||||||
|
<div class="py-4">
|
||||||
|
<i class="bi bi-qr-code fs-1 text-muted opacity-25 d-block mb-3"></i>
|
||||||
|
<h6 class="text-muted mb-3">QR Code belum dijana</h6>
|
||||||
|
<p class="text-muted small mb-4">
|
||||||
|
Jana QR Code untuk membolehkan peserta scan dan check-in ke program ini.
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="{{ route('admin.programs.qr.generate', $program) }}">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-qr-code me-2"></i>Jana QR Code
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- How to Use --}}
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-bottom py-3">
|
||||||
|
<span class="fw-semibold"><i class="bi bi-info-circle me-2 text-primary"></i>Cara Penggunaan</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ol class="ps-3 small text-muted" style="line-height: 2;">
|
||||||
|
<li>Jana QR Code untuk program ini.</li>
|
||||||
|
<li>Print atau papar QR Code di tempat program.</li>
|
||||||
|
<li>Peserta scan menggunakan kamera telefon.</li>
|
||||||
|
<li>Peserta isi maklumat check-in.</li>
|
||||||
|
<li>Sistem rekod kehadiran secara automatik.</li>
|
||||||
|
</ol>
|
||||||
|
<hr>
|
||||||
|
<div class="small text-muted">
|
||||||
|
<i class="bi bi-shield-check text-success me-1"></i>
|
||||||
|
QR Code mengandungi token unik — bukan ID program.
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted mt-2">
|
||||||
|
<i class="bi bi-exclamation-triangle text-warning me-1"></i>
|
||||||
|
Jana semula akan membatalkan QR Code lama.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm mt-3">
|
||||||
|
<div class="card-header bg-white border-bottom py-3">
|
||||||
|
<span class="fw-semibold"><i class="bi bi-calendar-check me-2 text-primary"></i>Status Program</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body small">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span class="text-muted">Status</span>
|
||||||
|
@include('admin.partials.program-status-badge', ['status' => $program->status])
|
||||||
|
</div>
|
||||||
|
@if($program->checkin_start_at)
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span class="text-muted">Check-In Dibuka</span>
|
||||||
|
<span>{{ $program->checkin_start_at->format('d/m H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<span class="text-muted">Check-In Ditutup</span>
|
||||||
|
<span>{{ $program->checkin_end_at?->format('d/m H:i') ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($program->ecert_download_start_at)
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span class="text-muted">Download Sijil</span>
|
||||||
|
<span>{{ $program->ecert_download_start_at->format('d/m H:i') }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
function copyUrl() {
|
||||||
|
const url = document.getElementById('checkinUrl').textContent.trim();
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
const icon = document.getElementById('copyIcon');
|
||||||
|
icon.className = 'bi bi-clipboard-check';
|
||||||
|
setTimeout(() => icon.className = 'bi bi-clipboard', 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
Reference in New Issue
Block a user