feat: certificate template management and generation (Fasa 7)

- CertificateService: Intervention Image v3 text overlay on template
- GenerateCertificateJob: queued generation with retry logic
- SendCertificateEmailJob: stub (implemented in Fasa 8)
- CertificateTemplateController: upload, config editor, preview, test generate
- Admin/CertificateController: list, generate-all, email-all
- Public/CertificateController: show with questionnaire gate, download
- DejaVuSans fonts bundled under resources/fonts
- Views: admin template/certificate management, public certificate download

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Saufi
2026-05-16 22:18:23 +08:00
parent 2f76f94283
commit 2ddc7e3caf
11 changed files with 993 additions and 3 deletions

View File

@@ -0,0 +1,135 @@
@extends('layouts.admin')
@section('title', 'Sijil — ' . $program->title)
@section('header', 'Pengurusan Sijil')
@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">Sijil</li>
@endsection
@section('content')
{{-- Stats --}}
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card border-0 bg-secondary bg-opacity-10 text-center p-3">
<div class="fs-3 fw-bold">{{ $stats['total'] }}</div>
<div class="small text-muted">Jumlah</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-success bg-opacity-10 text-center p-3">
<div class="fs-3 fw-bold text-success">{{ $stats['generated'] }}</div>
<div class="small text-muted">Dijana</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-warning bg-opacity-10 text-center p-3">
<div class="fs-3 fw-bold text-warning">{{ $stats['pending'] }}</div>
<div class="small text-muted">Menunggu</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 bg-danger bg-opacity-10 text-center p-3">
<div class="fs-3 fw-bold text-danger">{{ $stats['failed'] }}</div>
<div class="small text-muted">Gagal</div>
</div>
</div>
</div>
{{-- Actions --}}
<div class="d-flex gap-2 mb-4 flex-wrap">
<form method="POST" action="{{ route('admin.programs.certificates.generate-all', $program) }}">
@csrf
<button class="btn btn-primary"
onclick="return confirm('Jana sijil untuk semua peserta hadir?')">
<i class="bi bi-gear me-1"></i> Jana Semua Sijil
</button>
</form>
@if($stats['generated'] > 0)
<form method="POST" action="{{ route('admin.programs.certificates.email-all', $program) }}">
@csrf
<button class="btn btn-outline-success"
onclick="return confirm('Hantar emel sijil kepada semua peserta yang sijilnya sudah sedia?')">
<i class="bi bi-envelope me-1"></i> Hantar Emel Sijil
</button>
</form>
@endif
@if(! $program->certificateTemplate)
<div class="alert alert-warning mb-0 py-2 px-3 small">
<i class="bi bi-exclamation-triangle me-1"></i>
<a href="{{ route('admin.programs.template.show', $program) }}">Upload template sijil</a> dahulu sebelum jana sijil.
</div>
@endif
</div>
{{-- Certificate List --}}
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Peserta</th>
<th>No. Sijil</th>
<th>Status</th>
<th>Dijana</th>
<th>Muat Turun</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($certificates as $cert)
<tr>
<td>
<div class="fw-medium small">{{ $cert->participant->name }}</div>
<div class="text-muted" style="font-size:0.75rem;">{{ $cert->participant->agency ?: '—' }}</div>
</td>
<td class="small text-muted">{{ $cert->certificate_no ?? '—' }}</td>
<td>
@if($cert->status === 'generated' || $cert->status === 'downloaded')
<span class="badge bg-success">Sedia</span>
@elseif($cert->status === 'emailed')
<span class="badge bg-info">Diemailkan</span>
@elseif($cert->status === 'pending')
<span class="badge bg-warning text-dark">Menunggu</span>
@elseif($cert->status === 'generating')
<span class="badge bg-secondary">Menjana...</span>
@elseif($cert->status === 'failed')
<span class="badge bg-danger" title="{{ $cert->error_message }}">Gagal</span>
@endif
</td>
<td class="small text-muted">{{ $cert->generated_at?->format('d/m H:i') ?? '—' }}</td>
<td class="small text-muted text-center">{{ $cert->download_count ?: '—' }}</td>
<td>
@if($cert->isGenerated())
<a href="{{ route('public.certificate.show', $cert->token) }}" target="_blank"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="bi bi-award d-block fs-1 mb-3 opacity-25"></i>
Belum ada sijil dijana. Klik "Jana Semua Sijil" untuk mula.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($certificates->hasPages())
<div class="card-footer bg-white">
{{ $certificates->links() }}
</div>
@endif
</div>
@endsection