diff --git a/app/Http/Controllers/Admin/CertificateController.php b/app/Http/Controllers/Admin/CertificateController.php index a29df29..283a678 100644 --- a/app/Http/Controllers/Admin/CertificateController.php +++ b/app/Http/Controllers/Admin/CertificateController.php @@ -3,9 +3,104 @@ 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); + } } diff --git a/app/Http/Controllers/Admin/CertificateTemplateController.php b/app/Http/Controllers/Admin/CertificateTemplateController.php index 0cea7bc..269e6f8 100644 --- a/app/Http/Controllers/Admin/CertificateTemplateController.php +++ b/app/Http/Controllers/Admin/CertificateTemplateController.php @@ -3,9 +3,134 @@ 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.*.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): Response + { + $template = $program->certificateTemplate; + abort_if(! $template, 404); + + $sampleName = $request->input('sample_name', 'NAMA PESERTA CONTOH'); + $sampleNo = $request->input('sample_no', 'ECT/2025/0001'); + + $imageData = $service->generatePreview($template, $sampleName, $sampleNo); + + 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', + ], + ], + ]; + } } diff --git a/app/Http/Controllers/Public/CertificateController.php b/app/Http/Controllers/Public/CertificateController.php index ffedab5..8ca0694 100644 --- a/app/Http/Controllers/Public/CertificateController.php +++ b/app/Http/Controllers/Public/CertificateController.php @@ -3,9 +3,86 @@ 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), + ]); + } } diff --git a/app/Jobs/GenerateCertificateJob.php b/app/Jobs/GenerateCertificateJob.php new file mode 100644 index 0000000..1aba561 --- /dev/null +++ b/app/Jobs/GenerateCertificateJob.php @@ -0,0 +1,32 @@ +certificate->refresh(); + + if ($this->certificate->status === 'generated') { + return; + } + + $service->generate($this->certificate); + } +} diff --git a/app/Jobs/SendCertificateEmailJob.php b/app/Jobs/SendCertificateEmailJob.php new file mode 100644 index 0000000..32b54e1 --- /dev/null +++ b/app/Jobs/SendCertificateEmailJob.php @@ -0,0 +1,34 @@ +certificates() + ->whereIn('status', ['generated']) + ->whereNull('emailed_at') + ->each(fn($cert) => static::dispatch($cert)); + } +} diff --git a/app/Services/CertificateService.php b/app/Services/CertificateService.php new file mode 100644 index 0000000..79dbdb6 --- /dev/null +++ b/app/Services/CertificateService.php @@ -0,0 +1,126 @@ +manager = new ImageManager(new Driver()); + } + + public function generate(Certificate $certificate): void + { + $certificate->update(['status' => 'generating']); + + try { + $certificate->load(['participant', 'template', 'program']); + + $template = $certificate->template; + if (! $template) { + throw new \RuntimeException('Template sijil tidak dijumpai.'); + } + + $templatePath = Storage::path($template->image_path); + if (! file_exists($templatePath)) { + throw new \RuntimeException('Fail template sijil tidak dijumpai di storage.'); + } + + $image = $this->manager->read($templatePath); + $config = $template->config_json ?? []; + $fields = $config['fields'] ?? []; + + // Overlay name + if (isset($fields['name'])) { + $this->writeText($image, $certificate->participant->name, $fields['name']); + } + + // Overlay certificate number if configured + if (isset($fields['certificate_no']) && $certificate->certificate_no) { + $this->writeText($image, $certificate->certificate_no, $fields['certificate_no']); + } + + $outputDir = 'certificates/' . $certificate->program_id; + $outputFile = $outputDir . '/' . $certificate->uuid . '.jpg'; + + Storage::makeDirectory($outputDir); + $image->toJpeg(90)->save(Storage::path($outputFile)); + + $certificate->update([ + 'file_path' => $outputFile, + 'status' => 'generated', + 'generated_at' => now(), + 'error_message'=> null, + ]); + } catch (\Throwable $e) { + $certificate->update([ + 'status' => 'failed', + 'error_message' => $e->getMessage(), + ]); + } + } + + public function generatePreview(CertificateTemplate $template, string $sampleName, string $sampleNo = ''): string + { + $templatePath = Storage::path($template->image_path); + $image = $this->manager->read($templatePath); + $config = $template->config_json ?? []; + $fields = $config['fields'] ?? []; + + if (isset($fields['name'])) { + $this->writeText($image, $sampleName, $fields['name']); + } + + if (isset($fields['certificate_no']) && $sampleNo) { + $this->writeText($image, $sampleNo, $fields['certificate_no']); + } + + return $image->toJpeg(85)->toString(); + } + + private function writeText(\Intervention\Image\Image $image, string $text, array $cfg): void + { + $fontFile = $this->resolveFontPath($cfg['font_file'] ?? 'DejaVuSans-Bold.ttf'); + $fontSize = (int) ($cfg['font_size'] ?? 48); + $fontColor = (string)($cfg['font_color'] ?? '#000000'); + $align = (string)($cfg['align'] ?? 'center'); + $valign = (string)($cfg['valign'] ?? 'top'); + $x = (int) ($cfg['x'] ?? 0); + $y = (int) ($cfg['y'] ?? 0); + + $image->text($text, $x, $y, function (FontFactory $font) use ($fontFile, $fontSize, $fontColor, $align, $valign) { + $font->filename($fontFile); + $font->size($fontSize); + $font->color($fontColor); + $font->align($align); + $font->valign($valign); + }); + } + + private function resolveFontPath(string $filename): string + { + $custom = resource_path('fonts/' . $filename); + if (file_exists($custom)) { + return $custom; + } + return resource_path('fonts/DejaVuSans-Bold.ttf'); + } + + public function buildCertificateNo(Program $program, Participant $participant, int $sequence): string + { + $year = now()->format('Y'); + $prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $program->title), 0, 4)); + return sprintf('%s/%s/%04d', $prefix, $year, $sequence); + } +} diff --git a/resources/fonts/DejaVuSans-Bold.ttf b/resources/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..6d65fa7 Binary files /dev/null and b/resources/fonts/DejaVuSans-Bold.ttf differ diff --git a/resources/fonts/DejaVuSans.ttf b/resources/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..e5f7eec Binary files /dev/null and b/resources/fonts/DejaVuSans.ttf differ diff --git a/resources/views/admin/programs/certificates/index.blade.php b/resources/views/admin/programs/certificates/index.blade.php new file mode 100644 index 0000000..4f7af65 --- /dev/null +++ b/resources/views/admin/programs/certificates/index.blade.php @@ -0,0 +1,135 @@ +@extends('layouts.admin') + +@section('title', 'Sijil — ' . $program->title) +@section('header', 'Pengurusan Sijil') + +@section('breadcrumb') +
| Peserta | +No. Sijil | +Status | +Dijana | +Muat Turun | ++ |
|---|---|---|---|---|---|
|
+ {{ $cert->participant->name }}
+ {{ $cert->participant->agency ?: '—' }}
+ |
+ {{ $cert->certificate_no ?? '—' }} | ++ @if($cert->status === 'generated' || $cert->status === 'downloaded') + Sedia + @elseif($cert->status === 'emailed') + Diemailkan + @elseif($cert->status === 'pending') + Menunggu + @elseif($cert->status === 'generating') + Menjana... + @elseif($cert->status === 'failed') + Gagal + @endif + | +{{ $cert->generated_at?->format('d/m H:i') ?? '—' }} | +{{ $cert->download_count ?: '—' }} | ++ @if($cert->isGenerated()) + + + + @endif + | +
| + + Belum ada sijil dijana. Klik "Jana Semua Sijil" untuk mula. + | +|||||
+ Sijil anda sedang disediakan. Sila semak semula sebentar atau tunggu emel dari penganjur program. +
+ @if($certificate->status === 'failed') ++ Sebelum memuat turun sijil, anda perlu melengkapkan borang penilaian program. +
+ @if($qrCode) + + Isi Borang Penilaian + + @else ++ Tahniah, {{ $participant->name }}! Sijil digital anda untuk program ini telah sedia. +
+ +