Compare commits

...

44 Commits

Author SHA1 Message Date
Saufi
883a8391cb fixed webhook secret 2026-05-22 21:12:38 +08:00
Saufi
278fca5ee1 fixed webhook network 2026-05-22 16:41:39 +08:00
Saufi
aa508d0924 fixed webhook 2026-05-22 16:33:51 +08:00
Saufi
66c437ce92 fixed webhook 2026-05-22 16:28:42 +08:00
Saufi
bed6c93a01 fixed webhook 2026-05-22 16:22:03 +08:00
Saufi
d8cb554eaf edit webhook master 2026-05-22 16:14:17 +08:00
Saufi
2a67d937e8 tambah webhook 2026-05-22 16:12:05 +08:00
Saufi
d9ecdfc8f6 fixed peserta download esijil error 2026-05-22 15:52:28 +08:00
Saufi
91a950a816 fungsi delete program 2026-05-22 10:29:28 +08:00
Saufi
2aae3d2d6d update emel dari file 2026-05-20 22:18:51 +08:00
Saufi
9e5ff6b85e check headr file import peserta 2026-05-20 22:07:02 +08:00
Saufi
7e4bbca2db tambah fungsi upload peserta sebagai hadir 2026-05-20 20:10:43 +08:00
Saufi
154b2c650e add npm dalam docker 2026-05-20 17:05:00 +08:00
Saufi
fa0070acec fix bilangan berjaya emel 2026-05-20 16:15:48 +08:00
Saufi
afab039f54 tambah resend email 2026-05-20 15:44:28 +08:00
Saufi
17630c65a6 fix download error 2026-05-20 12:35:15 +08:00
Saufi
7027651dd7 fix status hantar emel dan jana sijil 2026-05-20 10:20:59 +08:00
Saufi
899507070c status emel 2026-05-20 09:11:51 +08:00
Saufi
6b2769d506 tambah emel masa semak sijil 2026-05-20 08:13:36 +08:00
Saufi
7ef5092933 tambah emel untuk kakitangan 2026-05-20 07:44:08 +08:00
Saufi
b48319f77d tukar logo mbip 2026-05-19 20:56:46 +08:00
Saufi
201595912f tukar nama MB Ipoh Perak kepada MBIP 2026-05-19 20:47:08 +08:00
Saufi
2642d0cb7c tukar myCert kepada mySijil 2026-05-19 18:59:44 +08:00
Saufi
10d0ae5671 fix: toggle No. Sijil kekal off selepas simpan konfigurasi 2026-05-19 18:17:10 +08:00
Saufi
6923f7b7eb fix: tukar timezone app ke Asia/Kuala_Lumpur 2026-05-19 18:09:14 +08:00
Saufi
ac319aea1f tukar nama mbip 2026-05-19 18:04:18 +08:00
Saufi
e37044153c fix: jalankan composer install sebelum tunggu MySQL — elak stuck di wait loop 2026-05-19 17:57:41 +08:00
Saufi
32c6d1b168 fix: jalankan composer install dalam production jika vendor/ tiada 2026-05-19 16:40:08 +08:00
Saufi
5a529641dd fix: tukar env_file ke src/.env — satu .env untuk Docker dan Laravel 2026-05-19 16:31:20 +08:00
Saufi
6238941aff env docker 2026-05-19 16:24:00 +08:00
Saufi
bf53c71b45 refactor: susun semula struktur folder — Laravel source ke src/ 2026-05-19 15:58:35 +08:00
Saufi
f052251b94 setting php.ini 2026-05-19 15:45:23 +08:00
Saufi
e65fd77156 fix: buat direktori storage pada deploy pertama sebelum view:cache 2026-05-19 15:36:28 +08:00
Saufi
24bac933a8 setting php dalam docker 2026-05-19 15:07:15 +08:00
Saufi
b0eec13d5b first 2026-05-19 09:53:36 +08:00
Saufi
f39eca4b1c feat: input field saiz font No IC dalam konfigurasi template
- Tambah fields[name][ic_font_size] dalam form — baris: Warna | Saiz Font No IC | Align
- Default: 70% daripada saiz font nama (sebelum ini hardcode 50%)
- loadPreview() hantar ic_font_size terkini ke endpoint pratonton
- writeIcBelow() baca ic_font_size dari config, fallback 70% jika tiada
- Validasi updateConfig: ic_font_size nullable|integer|min:8|max:200

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:18:18 +08:00
Saufi
12aea2cbff feat: papar no. IC di bawah nama dalam sijil dan pratonton
- writeIcBelow(): auto-kira kedudukan Y (nama_y + nama_font * 1.5)
  dan saiz font (50% daripada saiz font nama), align sama dengan nama
- generate(): tulis no_kp peserta sebenar di bawah nama
- generatePreview(): tulis contoh '800808-08-8888' di bawah nama sample
- Guna font DejaVuSans.ttf (regular) untuk IC, Bold untuk nama

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:06:44 +08:00
Saufi
d597bf45fb fix: pratonton guna koordinat form semasa, No. Sijil ikut toggle
- loadPreview() hantar semua nilai field (X, Y, font_size, color, align) ke endpoint
- certificate_no disertakan hanya jika toggle showCertNo aktif
- testGenerate() bina liveFields dari request, gabung dengan config tersimpan
  (supaya font_file & valign kekal dari config asal)
- generatePreview() terima overrideFields optional — preview sentiasa refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:46:21 +08:00
Saufi
0417a6698a feat: susun semula layout urus template sijil
- Panduan Template di bahagian atas, boleh lipat/kembang
- Template Aktif (kiri) bersebelahan Konfigurasi Teks (kanan) — col-lg-6
- Auto-detect portrait/landscape dari naturalWidth/naturalHeight imej
- Portrait: max-height 520px | Landscape: max-height 340px
- Badge orientasi (hijau=Landscape, biru=Portrait) dalam header kad
- Laras tinggi juga untuk pratonton upload form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:32:27 +08:00
Saufi
29d85eea86 fix: kemas kini CertificateService untuk Intervention Image v4 API
v3 → v4 breaking changes:
- manager->read()       → manager->decodePath()
- image->toJpeg($q)     → image->encode(new JpegEncoder($q))
- font->align($h) + font->valign($v) → font->align($h, $v)
- Storage::path()       → Storage::disk('local')->path() (eksplisit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:01:47 +08:00
Saufi
0fd202f974 fix: guna disk('public') dan preview route untuk papar imej di show.blade.php
- Tab QR: Storage::disk('public')->url() — selaras dengan fix QrCodeService
- Tab Template: guna route preview (controller baca dari private disk)
  Storage::url() tanpa disk pada private storage tidak boleh diakses terus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:40:37 +08:00
Saufi
756b73e3ee fix: QR code guna Storage::disk('public') — imej tidak papar di admin panel
Storage::put() guna default disk (local/private) menyebabkan fail disimpan
di storage/app/private/public/qrcodes/ tapi URL /storage/qrcodes/... cari
fail di storage/app/public/qrcodes/ melalui symlink — lokasi berbeza.

- QrCodeService: guna disk('public'), path ringkas 'qrcodes/{token}.png'
- View: Storage::disk('public')->url() untuk URL yang betul

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:37:42 +08:00
Saufi
55c077ee48 fix: guna PHP PDO untuk semak MySQL dalam entrypoint
mysqladmin resolve host.docker.internal ke IPv6 dahulu — MySQL Windows
tidak dengar pada IPv6, menyebabkan loop tak berakhir dan 502 pada Nginx.
PHP PDO guna IPv4 terus dan berjaya sambung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:24:38 +08:00
Saufi
69c91dfb4b feat: guna MySQL external — host.docker.internal (dev) & 172.17.200.16 (prod)
- Buang service db dan volume dbdata dari compose files
- dev: extra_hosts host.docker.internal:host-gateway → capai MySQL Windows host
- prod: IP terus 172.17.200.16, tiada extra_hosts diperlukan
- .env.docker: DB_HOST=host.docker.internal dengan nota untuk production
- entrypoint.sh: default DB_HOST → host.docker.internal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:34:17 +08:00
10830 changed files with 1391451 additions and 2462 deletions

View File

@@ -8,41 +8,40 @@
.gitattributes
# Dependencies (akan dipasang semula dalam container)
node_modules
vendor
src/node_modules
src/vendor
# Environment secrets
.env
.env.*
!.env.docker
!.env.example
!src/.env.example
# Build output (akan dihasilkan semula)
public/hot
public/build
# Build output
src/public/hot
src/public/build
# Dev tools
.idea
.vscode
*.code-workspace
.editorconfig
.phpunit.cache
phpunit.xml
src/.editorconfig
src/.phpunit.cache
src/phpunit.xml
# Docker files (tidak perlu dalam app container)
# Docker Compose files
docker-compose*.yml
docker/
# docker/ TIDAK diexclude — Dockerfile perlukan docker/entrypoint.sh
# Logs & cache
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*
src/storage/logs/*
src/storage/framework/cache/*
src/storage/framework/sessions/*
src/storage/framework/views/*
src/bootstrap/cache/*
# OS
.DS_Store
Thumbs.db
# Tests
tests/
src/tests/

33
.gitignore vendored
View File

@@ -1,32 +1,5 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
!src/.env.example
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
# Application-specific storage (private files)
/storage/app/private/certificates/
/storage/app/private/imports/
/storage/app/public/qrcodes/
node_modules
docker/webhook/hooks.json

View File

@@ -1,36 +0,0 @@
<?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

@@ -1,154 +0,0 @@
<?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',
]);
$stats = [
'total_participants' => $program->programParticipants()->count(),
'pre_registered' => $program->programParticipants()->where('is_pre_registered', true)->count(),
'walk_in' => $program->programParticipants()->where('registration_source', 'walk_in')->count(),
'total_attendances' => $program->attendances()->count(),
'total_certificates' => $program->certificates()->count(),
'generated_certificates'=> $program->certificates()->whereIn('status', ['generated', 'emailed', 'downloaded'])->count(),
];
return view('admin.programs.show', compact('program', 'stats'));
}
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

@@ -1,89 +0,0 @@
<?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
{
$data = $request->validate([
'question_text' => 'required|string|max:1000',
'question_type' => 'required|in:rating,single_choice,multiple_choice,short_text,long_text',
'is_required' => 'boolean',
'options' => 'nullable|array',
'options.*' => 'required|string|max:255',
]);
$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();
}
$maxOrder = $set->questions()->max('sort_order') ?? 0;
$set->questions()->create([
'question_text' => $data['question_text'],
'question_type' => $data['question_type'],
'is_required' => $data['is_required'] ?? true,
'options_json' => $needsOptions ? array_values(array_filter($data['options'] ?? [])) : null,
'sort_order' => $maxOrder + 1,
]);
return redirect()->route('admin.questionnaires.show', $set)
->with('success', 'Soalan berjaya ditambah.');
}
public function update(Request $request, QuestionnaireQuestion $question): RedirectResponse
{
$data = $request->validate([
'question_text' => 'required|string|max:1000',
'question_type' => 'required|in:rating,single_choice,multiple_choice,short_text,long_text',
'is_required' => 'boolean',
'options' => 'nullable|array',
'options.*' => 'required|string|max:255',
]);
$needsOptions = in_array($data['question_type'], ['single_choice', 'multiple_choice']);
$question->update([
'question_text' => $data['question_text'],
'question_type' => $data['question_type'],
'is_required' => $data['is_required'] ?? true,
'options_json' => $needsOptions ? array_values(array_filter($data['options'] ?? [])) : null,
]);
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;
$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',
]);
foreach ($data['order'] as $sortOrder => $questionId) {
QuestionnaireQuestion::where('id', $questionId)
->update(['sort_order' => $sortOrder + 1]);
}
return response()->json(['ok' => true]);
}
}

View File

@@ -1,82 +0,0 @@
<?php
namespace App\Jobs;
use App\Mail\CertificateReadyMail;
use App\Models\Certificate;
use App\Models\EmailLog;
use App\Models\Program;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendCertificateEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(public readonly Certificate $certificate) {}
public function handle(): void
{
$cert = $this->certificate->refresh();
$cert->load(['participant', 'program']);
$email = $cert->participant->email;
if (! $email) {
return;
}
try {
Mail::to($email)->send(new CertificateReadyMail($cert));
$cert->update([
'status' => 'emailed',
'emailed_at' => now(),
]);
EmailLog::create([
'program_id' => $cert->program_id,
'participant_id' => $cert->participant_id,
'certificate_id' => $cert->id,
'recipient_email'=> $email,
'subject' => 'Sijil Digital Program — ' . $cert->program->title,
'email_type' => 'certificate_ready',
'status' => 'sent',
'sent_at' => now(),
]);
} catch (\Throwable $e) {
EmailLog::create([
'program_id' => $cert->program_id,
'participant_id' => $cert->participant_id,
'certificate_id' => $cert->id,
'recipient_email'=> $email,
'subject' => 'Sijil Digital Program — ' . $cert->program->title,
'email_type' => 'certificate_ready',
'status' => 'failed',
'error_message' => $e->getMessage(),
]);
throw $e;
}
}
public static function dispatchBatch(Program $program): void
{
$program->certificates()
->whereIn('status', ['generated'])
->whereNull('emailed_at')
->with('participant')
->each(function (Certificate $cert) {
if ($cert->participant->email) {
static::dispatch($cert);
}
});
}
}

View File

@@ -1,32 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class QuestionnaireQuestion extends Model
{
protected $fillable = [
'questionnaire_set_id', 'question_text', 'question_type',
'options_json', 'is_required', 'sort_order',
];
protected function casts(): array
{
return [
'options_json' => 'array',
'is_required' => 'boolean',
'sort_order' => 'integer',
];
}
public function questionnaireSet()
{
return $this->belongsTo(QuestionnaireSet::class);
}
public function answers()
{
return $this->hasMany(QuestionnaireAnswer::class);
}
}

View File

@@ -1,101 +0,0 @@
<?php
namespace App\Services;
use App\Models\Participant;
use App\Models\Program;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use League\Csv\Reader;
class ParticipantImportService
{
public function import(Program $program, UploadedFile $file, ?string $defaultSession): array
{
$result = ['success' => 0, 'duplicates' => 0, 'failed' => 0, 'errors' => []];
$csv = Reader::createFromPath($file->getRealPath(), 'r');
$csv->setHeaderOffset(0);
// Strip UTF-8 BOM if present (Excel-exported CSV)
$csv->setOutputBOM('');
try {
$csv->addStreamFilter('convert.iconv.UTF-8/UTF-8');
} catch (\Throwable) {}
foreach ($csv->getRecords() as $rowNum => $row) {
$row = array_map('trim', $row);
// Normalise header keys (lowercase, strip BOM)
$row = array_combine(
array_map(fn($k) => strtolower(preg_replace('/[\x{FEFF}\s]/u', '', $k)), array_keys($row)),
array_values($row)
);
$data = [
'name' => $row['name'] ?? $row['nama'] ?? '',
'no_kp' => preg_replace('/[^0-9]/', '', $row['no_kp'] ?? $row['nokp'] ?? $row['ic'] ?? ''),
'email' => $row['email'] ?? $row['emel'] ?? null,
'phone' => $row['phone'] ?? $row['telefon'] ?? $row['phone'] ?? null,
'agency' => $row['agency'] ?? $row['agensi'] ?? $row['jabatan'] ?? null,
];
// Validate row
$validator = Validator::make($data, [
'name' => ['required', 'string', 'max:255'],
'no_kp' => ['required', 'digits:12'],
'email' => ['nullable', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
]);
if ($validator->fails()) {
$result['failed']++;
$result['errors'][] = 'Baris ' . ($rowNum + 2) . ': ' . implode(', ', $validator->errors()->all());
continue;
}
try {
DB::transaction(function () use ($program, $data, $defaultSession, &$result) {
// Find or create participant by no_kp
$participant = Participant::firstOrCreate(
['no_kp' => $data['no_kp']],
[
'name' => $data['name'],
'email' => $data['email'] ?: null,
'phone' => $data['phone'] ?: null,
'agency' => $data['agency'] ?: null,
'participant_type' => 'staff',
]
);
// Check duplicate in this program
$exists = $program->programParticipants()
->where('participant_id', $participant->id)
->exists();
if ($exists) {
$result['duplicates']++;
return;
}
$program->programParticipants()->create([
'participant_id' => $participant->id,
'registration_source' => 'import',
'is_pre_registered' => true,
'pre_registered_session' => $defaultSession,
'status' => 'registered',
'registered_at' => now(),
]);
$result['success']++;
});
} catch (\Throwable $e) {
$result['failed']++;
$result['errors'][] = 'Baris ' . ($rowNum + 2) . ': Ralat sistem — ' . $e->getMessage();
}
}
return $result;
}
}

27
deploy.sh Normal file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# eCert MBIP — Production Deploy Script
# Dipanggil oleh webhook selepas git push ke GitHub
set -e
PROJECT_DIR="/srv/ecert"
LOG="$PROJECT_DIR/deploy.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG"; }
log "=== Deploy dimulakan ==="
cd "$PROJECT_DIR"
log "git pull..."
git pull origin master
log "migrate database..."
docker exec ecert_app php artisan migrate --force
log "optimize cache..."
docker exec ecert_app php artisan optimize
log "restart queue worker..."
docker restart ecert_queue
log "=== Deploy selesai ==="

View File

@@ -4,12 +4,14 @@
# Penggunaan:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# DB external: 172.17.200.16:3306
# Dalam .env (production): DB_HOST=172.17.200.16
#
# Perbezaan dari dev:
# • APP_ENV=production, APP_DEBUG=false
# • DB port TIDAK didedahkan ke host
# • Storage sijil/template disimpan dalam named volume (kekal semasa deploy)
# • Opcache validate_timestamps=0 (prestasi)
# • Storage sijil/template dalam named volume (kekal semasa redeploy)
# • php-dev.ini tidak dimuat
# • extra_hosts dibuang (IP terus boleh dicapai dari container)
###############################################################################
name: ecert
@@ -20,44 +22,61 @@ services:
container_name: ecert_app
restart: always
volumes:
# Kod dari server (git pull)
- .:/var/www
# php.ini sahaja (tanpa php-dev.ini)
- ./src:/var/www
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
# Storage kekal semasa redeploy
- storage_data:/var/www/storage
environment:
APP_ENV: production
APP_DEBUG: "false"
extra_hosts: [] # buang host.docker.internal, guna IP terus
# ── Nginx (production) ─────────────────────────────────────────────────────
nginx:
container_name: ecert_nginx
restart: always
volumes:
- .:/var/www:ro
- ./src:/var/www:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
# Mount storage supaya nginx boleh serve fail statik jika perlu
- storage_data:/var/www/storage:ro
# ── MySQL (production) ─────────────────────────────────────────────────────
db:
container_name: ecert_db
restart: always
ports: [] # Jangan dedahkan DB port ke luar dalam production
# ── Node.js Asset Builder (one-time, run manually) ────────────────────────
node-build:
image: node:lts-alpine
container_name: ecert_node_build
working_dir: /app
volumes:
- dbdata:/var/lib/mysql
- ./src:/app
command: sh -c "npm ci && npm run build"
profiles:
- build
# ── Queue Worker (production) ──────────────────────────────────────────────
queue:
container_name: ecert_queue
restart: always
volumes:
- .:/var/www
- ./src:/var/www
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
- storage_data:/var/www/storage
environment:
APP_ENV: production
extra_hosts: []
# ── Webhook Deploy (GitHub → auto pull + migrate) ──────────────────────────
webhook:
build:
context: ./docker/webhook
container_name: ecert_webhook
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /root/.ssh:/root/.ssh:ro
- ./docker/webhook/hooks.json:/etc/webhook/hooks.json:ro
- ./deploy.sh:/deploy.sh:ro
- .:/srv/ecert
command: -hooks=/etc/webhook/hooks.json -verbose
networks:
- ecert
###############################################################################
volumes:

View File

@@ -1,11 +1,15 @@
###############################################################################
# eCert MBIP — Docker Compose (Development — Windows 11 / Linux)
# eCert MBIP — Docker Compose (Development — Windows 11)
#
# Penggunaan:
# docker compose up -d --build
#
# Aplikasi: http://localhost:8003
# DB (host): localhost:33060 (untuk TablePlus / HeidiSQL)
# Aplikasi: http://localhost:8003
# DB : host.docker.internal:3306 (MySQL pada Windows host)
#
# NOTA: Dari dalam container, MySQL pada Windows tidak boleh guna "localhost".
# Kena guna host.docker.internal (disediakan oleh Docker Desktop).
# Dalam .env: DB_HOST=host.docker.internal
###############################################################################
name: ecert
@@ -20,17 +24,16 @@ services:
restart: unless-stopped
working_dir: /var/www
volumes:
- .:/var/www
- ./src:/var/www
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
- ./docker/php/php-dev.ini:/usr/local/etc/php/conf.d/99-ecert-dev.ini:ro
env_file:
- .env
- src/.env
environment:
APP_ENV: local
APP_DEBUG: "true"
depends_on:
db:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway" # pastikan resolusi host pada Linux juga
networks:
- ecert
@@ -42,44 +45,13 @@ services:
ports:
- "8003:80"
volumes:
- .:/var/www:ro
- ./src:/var/www:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- ecert
# ── MySQL 8.0 ──────────────────────────────────────────────────────────────
db:
image: mysql:8.0
container_name: ecert_db
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_DATABASE:-ecert_mbip}
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-secret}
MYSQL_USER: ${DB_USERNAME:-ecert}
MYSQL_PASSWORD: ${DB_PASSWORD:-secret}
volumes:
- dbdata:/var/lib/mysql
ports:
- "33060:3306" # port host 33060 → elak konflik dengan MySQL tempatan (3306)
healthcheck:
test:
- CMD
- mysqladmin
- ping
- -h
- localhost
- -u
- root
- --password=${DB_PASSWORD:-secret}
interval: 5s
timeout: 5s
retries: 15
start_period: 20s
networks:
- ecert
# ── Queue Worker ───────────────────────────────────────────────────────────
queue:
build:
@@ -89,13 +61,14 @@ services:
restart: unless-stopped
working_dir: /var/www
volumes:
- .:/var/www
- ./src:/var/www
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
env_file:
- .env
- src/.env
environment:
APP_ENV: local
# Override entrypoint: langkau migrate/seed (app container dah buat)
extra_hosts:
- "host.docker.internal:host-gateway"
entrypoint: []
command:
- php
@@ -106,10 +79,7 @@ services:
- --max-time=3600
- --timeout=90
depends_on:
db:
condition: service_healthy
app:
condition: service_started
- app
networks:
- ecert
@@ -117,7 +87,3 @@ services:
networks:
ecert:
driver: bridge
volumes:
dbdata:
driver: local

View File

@@ -17,8 +17,26 @@ echo "║ eCert MBIP — Container Start ║"
echo "╚══════════════════════════════════════╝"
echo ""
# ── 1. Tunggu MySQL bersedia ──────────────────────────────────────────────────
DB_HOST="${DB_HOST:-db}"
# ── 1. Pasang Composer dependencies (sebelum tunggu MySQL) ───────────────────
if [ ! -d /var/www/vendor ]; then
echo "📦 Memasang Composer dependencies..."
if [ "${APP_ENV}" = "production" ]; then
composer install \
--no-interaction \
--no-dev \
--no-progress \
--prefer-dist \
--optimize-autoloader
else
composer install \
--no-interaction \
--no-progress \
--prefer-dist
fi
fi
# ── 2. Tunggu MySQL bersedia ──────────────────────────────────────────────────
DB_HOST="${DB_HOST:-host.docker.internal}"
DB_PORT="${DB_PORT:-3306}"
DB_DATABASE="${DB_DATABASE:-ecert_mbip}"
DB_USERNAME="${DB_USERNAME:-root}"
@@ -26,26 +44,27 @@ DB_PASSWORD="${DB_PASSWORD:-secret}"
echo "⏳ Menunggu MySQL di ${DB_HOST}:${DB_PORT}..."
until mysqladmin ping \
-h "${DB_HOST}" \
-P "${DB_PORT}" \
-u "${DB_USERNAME}" \
--password="${DB_PASSWORD}" \
--silent 2>/dev/null; do
until php -r "
try {
new PDO(
'mysql:host=${DB_HOST};port=${DB_PORT};dbname=${DB_DATABASE}',
'${DB_USERNAME}',
'${DB_PASSWORD}'
);
exit(0);
} catch (Exception \$e) {
exit(1);
}
" 2>/dev/null; do
printf "."
sleep 2
done
echo ""
echo "✓ MySQL bersedia."
# ── 2. Pasang Composer dependencies (development sahaja) ─────────────────────
if [ "${APP_ENV}" != "production" ] && [ ! -d /var/www/vendor ]; then
echo "📦 Memasang Composer dependencies (dev)..."
composer install \
--no-interaction \
--no-progress \
--prefer-dist
fi
# ── 2b. Fix storage permissions (penting untuk named volume di production) ────
chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache 2>/dev/null || true
chmod -R 775 /var/www/storage /var/www/bootstrap/cache 2>/dev/null || true
# ── 3. Generate APP_KEY jika kosong ───────────────────────────────────────────
if [ -z "${APP_KEY}" ] || [ "${APP_KEY}" = "" ]; then
@@ -66,13 +85,20 @@ php artisan storage:link 2>/dev/null || true
# ── 6. Cache (production sahaja) ──────────────────────────────────────────────
if [ "${APP_ENV}" = "production" ]; then
# Pastikan direktori storage wujud (penting bila named volume kosong pada deploy pertama)
mkdir -p /var/www/storage/framework/views \
/var/www/storage/framework/cache/data \
/var/www/storage/framework/sessions \
/var/www/storage/logs \
/var/www/storage/app/public
chown -R www-data:www-data /var/www/storage
chmod -R 775 /var/www/storage
echo "⚡ Caching config, routes, views..."
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# Opcache: matikan validate_timestamps untuk prestasi
# (sudah dikonfigur dalam php.ini prod)
fi
echo ""

View File

@@ -62,6 +62,13 @@ server {
try_files $uri =404;
}
# ── GitHub Webhook Deploy ─────────────────────────────────────────────────
location /hooks/ {
proxy_pass http://ecert_webhook:9000/hooks/;
proxy_set_header Host $host;
proxy_read_timeout 60s;
}
# ── Halang akses fail tersembunyi ─────────────────────────────────────────
location ~ /\. {
deny all;

View File

@@ -4,7 +4,7 @@
###############################################################################
FROM php:8.4-fpm
LABEL org.opencontainers.image.title="eCert MBIP" \
LABEL org.opencontainers.image.title="mySijil MBIP" \
org.opencontainers.image.description="Sistem Pengurusan Sijil Digital MBIP"
# ── System libraries ──────────────────────────────────────────────────────────

View File

@@ -39,6 +39,13 @@ display_startup_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
; ── Output buffering (elak "headers already sent" dari PHP notices) ───────────
output_buffering = 4096
; ── Temporary files ───────────────────────────────────────────────────────────
sys_temp_dir = /tmp
upload_tmp_dir = /tmp
; ── imagick ───────────────────────────────────────────────────────────────────
[imagick]
imagick.skip_version_check = 1

View File

@@ -0,0 +1,8 @@
FROM golang:1.23-alpine AS builder
RUN go install github.com/adnanh/webhook@2.8.1
FROM alpine:3.21
RUN apk add --no-cache git docker-cli
COPY --from=builder /go/bin/webhook /usr/local/bin/webhook
EXPOSE 9000
ENTRYPOINT ["/usr/local/bin/webhook"]

18
docker/webhook/hooks.json Normal file
View File

@@ -0,0 +1,18 @@
[
{
"id": "deploy",
"execute-command": "/deploy.sh",
"command-working-directory": "/srv/ecert",
"response-message": "Deploy dimulakan.",
"trigger-rule": {
"match": {
"type": "payload-hmac-sha256",
"secret": "{{ .Env.WEBHOOK_SECRET }}",
"parameter": {
"source": "header",
"name": "X-Hub-Signature-256"
}
}
}
}
]

Binary file not shown.

722
manual/generate_manual.py Normal file
View File

@@ -0,0 +1,722 @@
"""
Penjana Manual Pengguna eCert MBIP
Format: Microsoft Word (.docx)
"""
from docx import Document
from docx.shared import Pt, Cm, RGBColor, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
import datetime
BASE_URL = "https://mysijil.mbip.my"
doc = Document()
# ── Margin halaman ────────────────────────────────────────────────────────────
for section in doc.sections:
section.top_margin = Cm(2.5)
section.bottom_margin = Cm(2.5)
section.left_margin = Cm(3.0)
section.right_margin = Cm(2.5)
# ── Helpers ───────────────────────────────────────────────────────────────────
def add_heading(text, level=1):
h = doc.add_heading(text, level=level)
h.runs[0].font.color.rgb = RGBColor(0x1a, 0x3a, 0x6b)
return h
def add_body(text):
p = doc.add_paragraph(text)
p.runs[0].font.size = Pt(11)
return p
def add_bullet(text):
p = doc.add_paragraph(text, style='List Bullet')
p.runs[0].font.size = Pt(11)
return p
def add_screenshot_box(caption, url, height_cm=7):
"""Kotak placeholder untuk tangkapan skrin."""
doc.add_paragraph()
# Jadual satu sel sebagai kotak
tbl = doc.add_table(rows=1, cols=1)
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
cell = tbl.cell(0, 0)
# Warna latar
tc = cell._tc
tcPr = tc.get_or_add_tcPr()
shd = OxmlElement('w:shd')
shd.set(qn('w:val'), 'clear')
shd.set(qn('w:color'), 'auto')
shd.set(qn('w:fill'), 'EEF2FF')
tcPr.append(shd)
# Tinggi sel
trPr = tbl.rows[0]._tr.get_or_add_trPr()
trHeight = OxmlElement('w:trHeight')
trHeight.set(qn('w:val'), str(int(height_cm * 567)))
trHeight.set(qn('w:hRule'), 'exact')
trPr.append(trHeight)
# Teks dalam kotak
p = cell.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run(f"\n\n[ TANGKAPAN SKRIN ]\n\n{caption}")
run.font.size = Pt(10)
run.font.color.rgb = RGBColor(0x64, 0x74, 0x8B)
run.font.italic = True
# Label URL
p2 = doc.add_paragraph()
p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
run2 = p2.add_run(f"URL: {url}")
run2.font.size = Pt(9)
run2.font.color.rgb = RGBColor(0x1a, 0x56, 0xa0)
run2.font.italic = True
doc.add_paragraph()
def add_note(text):
p = doc.add_paragraph()
p.add_run("Nota: ").bold = True
run = p.add_run(text)
run.font.size = Pt(10)
run.font.italic = True
p.paragraph_format.left_indent = Cm(0.5)
def page_break():
doc.add_page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# HALAMAN TAJUK
# ═══════════════════════════════════════════════════════════════════════════════
doc.add_paragraph()
doc.add_paragraph()
doc.add_paragraph()
t = doc.add_paragraph("eCert MBIP")
t.alignment = WD_ALIGN_PARAGRAPH.CENTER
r = t.runs[0]
r.font.size = Pt(32)
r.font.bold = True
r.font.color.rgb = RGBColor(0x1a, 0x3a, 0x6b)
t2 = doc.add_paragraph("Sistem Pengurusan Sijil Digital")
t2.alignment = WD_ALIGN_PARAGRAPH.CENTER
r2 = t2.runs[0]
r2.font.size = Pt(18)
r2.font.color.rgb = RGBColor(0x1a, 0x3a, 0x6b)
doc.add_paragraph()
t3 = doc.add_paragraph("MANUAL PENGGUNA — PENTADBIR")
t3.alignment = WD_ALIGN_PARAGRAPH.CENTER
r3 = t3.runs[0]
r3.font.size = Pt(16)
r3.font.bold = True
r3.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
doc.add_paragraph()
doc.add_paragraph()
t4 = doc.add_paragraph("Majlis Bandaraya Ipoh Perak (MBIP)")
t4.alignment = WD_ALIGN_PARAGRAPH.CENTER
t4.runs[0].font.size = Pt(12)
t5 = doc.add_paragraph(f"Versi 1.0 · {datetime.date.today().strftime('%B %Y')}")
t5.alignment = WD_ALIGN_PARAGRAPH.CENTER
t5.runs[0].font.size = Pt(11)
t5.runs[0].font.color.rgb = RGBColor(0x88, 0x88, 0x88)
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# ISI KANDUNGAN (placeholder manual)
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("Isi Kandungan", level=1)
toc_items = [
("1", "Pengenalan"),
("2", "Log Masuk ke Sistem"),
("3", "Dashboard Utama"),
("4", "Pengurusan Program"),
(" 4.1", "Cipta Program Baru"),
(" 4.2", "Kemaskini Maklumat Program"),
(" 4.3", "Tetapan Check-in dan Muat Turun"),
(" 4.4", "Publish dan Tutup Program"),
("5", "Pengurusan Peserta"),
(" 5.1", "Lihat Senarai Peserta"),
(" 5.2", "Tambah Peserta Satu-Satu"),
(" 5.3", "Import Peserta dari Excel"),
(" 5.4", "Export Senarai Peserta"),
("6", "Kod QR Check-in"),
("7", "Template Sijil"),
(" 7.1", "Muat Naik Template"),
(" 7.2", "Konfigurasi Kedudukan Teks"),
(" 7.3", "Jana Pratonton Sijil"),
("8", "Soalselidik Program"),
("9", "Pengurusan Sijil"),
("10", "Statistik Program"),
("11", "Set Soalselidik"),
(" 11.1", "Cipta Set Soalselidik"),
(" 11.2", "Tambah dan Urus Soalan"),
("12", "Pengurusan Pengguna (Super Admin)"),
("13", "Profil Pengguna"),
]
for num, title in toc_items:
p = doc.add_paragraph()
p.paragraph_format.left_indent = Cm(0.5) if num.startswith(" ") else Cm(0)
r = p.add_run(f"{num.strip()} {title}")
r.font.size = Pt(11)
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 1: PENGENALAN
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("1. Pengenalan")
add_body(
"eCert MBIP ialah sistem pengurusan sijil digital yang dibangunkan untuk Majlis Bandaraya Ipoh Perak (MBIP). "
"Sistem ini membolehkan pentadbir mengurus program, menguruskan peserta, menjana sijil digital secara automatik, "
"dan mengumpul maklum balas peserta melalui soalselidik dalam talian."
)
doc.add_paragraph()
add_body("Fungsi utama sistem:")
add_bullet("Pengurusan program dan peserta")
add_bullet("Check-in peserta melalui kod QR")
add_bullet("Jana dan hantar sijil digital secara automatik")
add_bullet("Pengurusan template sijil")
add_bullet("Kutipan maklum balas melalui soalselidik")
add_bullet("Laporan statistik kehadiran dan penyertaan")
doc.add_paragraph()
add_body("Manual ini ditujukan kepada pentadbir sistem (Admin dan Super Admin) untuk menggunakan semua fungsi yang tersedia.")
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 2: LOG MASUK
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("2. Log Masuk ke Sistem")
add_body("Untuk mengakses sistem eCert MBIP, pentadbir perlu log masuk menggunakan alamat emel dan kata laluan yang telah diberikan.")
doc.add_paragraph()
add_body("Langkah-langkah log masuk:")
add_bullet(f"Buka pelayar web dan pergi ke: {BASE_URL}/login")
add_bullet("Masukkan Alamat Emel yang berdaftar.")
add_bullet("Masukkan Kata Laluan.")
add_bullet("Klik butang Log Masuk.")
doc.add_paragraph()
add_screenshot_box(
"Halaman Log Masuk — Borang emel dan kata laluan",
f"{BASE_URL}/login"
)
add_note(
"Jika terlupa kata laluan, klik pautan 'Terlupa Kata Laluan?' di bawah borang log masuk. "
"Pautan set semula kata laluan akan dihantar ke alamat emel yang didaftarkan."
)
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 3: DASHBOARD
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("3. Dashboard Utama")
add_body(
"Selepas log masuk, pentadbir akan dibawa ke halaman Dashboard. Dashboard memaparkan ringkasan aktiviti sistem "
"termasuk jumlah program aktif, jumlah peserta, dan sijil yang dijana."
)
doc.add_paragraph()
add_body("Elemen pada Dashboard:")
add_bullet("Jumlah program yang sedang aktif")
add_bullet("Senarai program terkini")
add_bullet("Pautan pantas ke fungsi utama")
add_screenshot_box(
"Dashboard Utama — Ringkasan statistik dan senarai program",
f"{BASE_URL}/admin/dashboard"
)
add_body("Bar navigasi di sebelah kiri (sidebar) menyediakan akses pantas ke semua modul sistem.")
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 4: PENGURUSAN PROGRAM
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("4. Pengurusan Program")
add_body(
"Modul Program adalah teras sistem eCert MBIP. Setiap program mewakili satu acara atau kursus yang dianjurkan. "
"Pentadbir boleh mencipta, mengemaskini, dan mengurus status program dari modul ini."
)
add_screenshot_box(
"Senarai Program — Semua program yang telah dicipta",
f"{BASE_URL}/admin/programs"
)
# 4.1
add_heading("4.1 Cipta Program Baru", level=2)
add_body("Langkah-langkah mencipta program baru:")
add_bullet("Klik butang + Cipta Program di halaman Senarai Program.")
add_bullet("Isi maklumat program:")
fields_program = [
("Tajuk Program", "Nama program atau acara (wajib)"),
("Penerangan", "Huraian ringkas program"),
("Tarikh Mula / Tamat", "Tarikh pelaksanaan program"),
("Lokasi", "Tempat program diadakan"),
("Benarkan Walk-in", "Aktifkan jika peserta luar dibenarkan daftar semasa check-in"),
]
for field, desc in fields_program:
p = doc.add_paragraph()
p.paragraph_format.left_indent = Cm(1)
r1 = p.add_run(f"{field}: ")
r1.bold = True
r1.font.size = Pt(11)
p.add_run(desc).font.size = Pt(11)
add_bullet("Klik Simpan untuk menyimpan program.")
add_screenshot_box(
"Borang Cipta Program Baru — Isi maklumat program",
f"{BASE_URL}/admin/programs/create"
)
# 4.2
add_heading("4.2 Kemaskini Maklumat Program", level=2)
add_body(
"Untuk mengedit program sedia ada, klik ikon Edit (pensel) pada senarai program atau klik nama program "
"kemudian pilih tab Butiran."
)
add_screenshot_box(
"Halaman Butiran Program — Tab maklumat, peserta, template, soalselidik, sijil",
f"{BASE_URL}/admin/programs/{{uuid}}"
)
# 4.3
add_heading("4.3 Tetapan Check-in dan Muat Turun", level=2)
add_body("Pentadbir perlu menetapkan waktu check-in dan tempoh muat turun sijil dalam tetapan program:")
settings_table = [
("Mula Check-in", "Tarikh dan masa check-in dibuka untuk peserta"),
("Tamat Check-in", "Tarikh dan masa check-in ditutup"),
("Mula Muat Turun Sijil", "Peserta boleh muat turun sijil selepas tempoh ini"),
("Tamat Muat Turun Sijil", "Tempoh muat turun sijil tamat"),
]
for field, desc in settings_table:
p = doc.add_paragraph()
p.paragraph_format.left_indent = Cm(1)
r1 = p.add_run(f"{field}: ")
r1.bold = True
r1.font.size = Pt(11)
p.add_run(desc).font.size = Pt(11)
add_note("Semua masa menggunakan waktu Malaysia (MYT, UTC+8).")
# 4.4
add_heading("4.4 Publish dan Tutup Program", level=2)
add_body("Program perlu di-publish sebelum peserta dapat menggunakan pautan check-in QR.")
add_bullet("Klik butang Publish untuk mengaktifkan program. Status bertukar kepada Aktif.")
add_bullet("Klik butang Tutup untuk menamatkan program. Peserta tidak lagi dapat check-in.")
add_note("Program yang telah ditutup tidak boleh dibuka semula secara automatik.")
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 5: PENGURUSAN PESERTA
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("5. Pengurusan Peserta")
add_body("Modul Peserta membolehkan pentadbir mengurus senarai peserta bagi setiap program.")
# 5.1
add_heading("5.1 Lihat Senarai Peserta", level=2)
add_body("Klik tab Peserta dalam halaman butiran program untuk melihat semua peserta berdaftar.")
add_body("Maklumat yang dipaparkan:")
add_bullet("Nama peserta")
add_bullet("No. Kad Pengenalan")
add_bullet("Status check-in (Hadir / Belum Hadir)")
add_bullet("Status sijil (Belum Jana / Dijana / Dihantar)")
add_bullet("Sumber pendaftaran (Pra-daftar / Walk-in)")
add_screenshot_box(
"Senarai Peserta — Status kehadiran dan sijil setiap peserta",
f"{BASE_URL}/admin/programs/{{uuid}}/participants"
)
# 5.2
add_heading("5.2 Tambah Peserta Satu-Satu", level=2)
add_body("Untuk menambah peserta secara manual:")
add_bullet("Klik butang + Tambah Peserta.")
add_bullet("Isi Nama Penuh dan No. Kad Pengenalan (12 digit).")
add_bullet("Isi maklumat tambahan jika perlu (emel, telefon, agensi).")
add_bullet("Klik Simpan.")
add_screenshot_box(
"Borang Tambah Peserta — Isi maklumat peserta",
f"{BASE_URL}/admin/programs/{{uuid}}/participants/create"
)
# 5.3
add_heading("5.3 Import Peserta dari Excel", level=2)
add_body("Untuk mendaftar ramai peserta sekaligus:")
add_bullet("Klik butang Import Excel.")
add_bullet("Muat turun templat Excel yang disediakan.")
add_bullet("Isi maklumat peserta dalam templat (Nama, No. KP, Emel, Telefon, Agensi).")
add_bullet("Muat naik semula fail Excel yang telah diisi.")
add_bullet("Semak ringkasan import dan klik Sahkan.")
add_screenshot_box(
"Halaman Import Peserta — Muat naik fail Excel",
f"{BASE_URL}/admin/programs/{{uuid}}/participants/import"
)
add_note("Sistem akan abaikan baris yang No. KP-nya sudah wujud dalam program yang sama.")
# 5.4
add_heading("5.4 Export Senarai Peserta", level=2)
add_body("Klik butang Export Excel untuk memuat turun senarai lengkap peserta beserta status check-in dan sijil ke dalam fail Excel.")
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 6: KOD QR CHECK-IN
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("6. Kod QR Check-in")
add_body(
"Setiap program mempunyai Kod QR unik yang digunakan peserta untuk check-in. "
"Peserta mengimbas kod ini menggunakan telefon pintar untuk mendaftarkan kehadiran."
)
add_body("Cara menjana dan menggunakan Kod QR:")
add_bullet("Klik tab Kod QR dalam halaman butiran program.")
add_bullet("Klik Jana QR Code jika belum dijana.")
add_bullet("Paparkan Kod QR pada skrin besar atau cetak untuk peserta mengimbas.")
add_bullet("Klik Muat Turun untuk menyimpan imej Kod QR.")
add_screenshot_box(
"Halaman Kod QR — Jana, papar, dan muat turun QR Code",
f"{BASE_URL}/admin/programs/{{uuid}}/qr"
)
add_note(
"Kod QR boleh dinyahaktifkan (Deactivate) dan dijana semula jika diperlukan. "
"Kod lama tidak akan berfungsi selepas dinyahaktifkan."
)
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 7: TEMPLATE SIJIL
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("7. Template Sijil")
add_body(
"Modul Template Sijil membolehkan pentadbir menyediakan reka bentuk sijil yang akan digunakan "
"untuk menjana sijil digital peserta."
)
# 7.1
add_heading("7.1 Muat Naik Template", level=2)
add_body("Langkah-langkah muat naik template sijil:")
add_bullet("Klik tab Template Sijil dalam halaman butiran program.")
add_bullet("Klik butang Pilih Fail dan pilih imej template (format JPG atau PNG, maksimum 10MB).")
add_bullet("Resolusi disyorkan: 1754 × 1240 piksel (A4 landscape) atau 1240 × 1754 piksel (portrait).")
add_bullet("Klik Muat Naik.")
add_screenshot_box(
"Halaman Muat Naik Template — Pilih fail imej sijil",
f"{BASE_URL}/admin/programs/{{uuid}}/template"
)
# 7.2
add_heading("7.2 Konfigurasi Kedudukan Teks", level=2)
add_body(
"Selepas template dimuat naik, pentadbir perlu menetapkan kedudukan teks pada sijil. "
"Koordinat dikira dari sudut kiri atas imej (piksel)."
)
add_body("Medan yang boleh dikonfigurasi:")
config_fields = [
("Nama Peserta", "Kedudukan X, Y, saiz font, warna, dan penjajaran (kiri/tengah/kanan)"),
("No. IC", "Saiz font No. IC yang dipaparkan di bawah nama"),
("No. Sijil (Pilihan)", "Aktifkan togol 'Papar' untuk menambah No. Sijil pada sijil. Tetapkan kedudukan X, Y."),
]
for field, desc in config_fields:
p = doc.add_paragraph()
p.paragraph_format.left_indent = Cm(1)
r1 = p.add_run(f"{field}: ")
r1.bold = True
r1.font.size = Pt(11)
p.add_run(desc).font.size = Pt(11)
add_bullet("Klik Simpan Konfigurasi untuk menyimpan tetapan.")
add_screenshot_box(
"Konfigurasi Template — Tetapkan kedudukan teks nama dan No. Sijil",
f"{BASE_URL}/admin/programs/{{uuid}}/template"
)
# 7.3
add_heading("7.3 Jana Pratonton Sijil", level=2)
add_body("Untuk menyemak kedudukan teks sebelum menjana sijil sebenar:")
add_bullet("Masukkan nama contoh dalam kotak Jana Pratonton.")
add_bullet("Klik butang Pratonton.")
add_bullet("Imej pratonton akan dipaparkan dengan teks pada koordinat yang ditetapkan.")
add_note("Pratonton menggunakan nilai koordinat terkini walaupun belum disimpan.")
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 8: SOALSELIDIK PROGRAM
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("8. Soalselidik Program")
add_body(
"Pentadbir boleh mengaitkan set soalselidik dengan program. Peserta akan diminta mengisi soalselidik "
"selepas check-in sebelum sijil boleh dimuat turun."
)
add_body("Cara mengaitkan soalselidik dengan program:")
add_bullet("Klik tab Soalselidik dalam halaman butiran program.")
add_bullet("Pilih Set Soalselidik yang ingin digunakan daripada senarai tersedia.")
add_bullet("Klik Lampirkan Soalselidik.")
add_bullet("Klik Sahkan untuk mengesahkan penggunaan soalselidik ini.")
add_bullet("Klik Pratonton untuk melihat soalan yang akan dijawab peserta.")
add_screenshot_box(
"Halaman Soalselidik Program — Lampirkan dan pratonton soalselidik",
f"{BASE_URL}/admin/programs/{{uuid}}/questionnaire"
)
add_note("Soalselidik yang telah disahkan tidak boleh ditukar. Sah kan hanya apabila sudah pasti.")
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 9: PENGURUSAN SIJIL
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("9. Pengurusan Sijil")
add_body(
"Modul Sijil membolehkan pentadbir menjana dan menghantar sijil digital kepada semua peserta yang hadir."
)
add_body("Fungsi yang tersedia:")
add_bullet("Jana Semua Sijil — Menjana sijil untuk semua peserta yang telah check-in.")
add_bullet("Hantar Emel Semua — Menghantar sijil kepada peserta melalui emel secara pukal.")
add_body("Status sijil setiap peserta:")
status_sijil = [
("Belum Jana", "Sijil belum dijana untuk peserta ini"),
("Dijana", "Sijil sudah dijana dan sedia untuk dihantar"),
("Dihantar", "Sijil telah dihantar melalui emel"),
("Dimuat Turun", "Peserta telah memuat turun sijil mereka"),
]
for status, desc in status_sijil:
p = doc.add_paragraph()
p.paragraph_format.left_indent = Cm(1)
r1 = p.add_run(f"{status}: ")
r1.bold = True
r1.font.size = Pt(11)
p.add_run(desc).font.size = Pt(11)
add_screenshot_box(
"Halaman Sijil — Senarai sijil dan fungsi jana/hantar pukal",
f"{BASE_URL}/admin/programs/{{uuid}}/certificates"
)
add_note("Pastikan template sijil telah dikonfigurasi terlebih dahulu sebelum menjana sijil.")
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 10: STATISTIK PROGRAM
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("10. Statistik Program")
add_body(
"Halaman Statistik memaparkan data analitik terperinci bagi setiap program. "
"Pentadbir boleh memantau prestasi program melalui laporan yang disediakan."
)
add_body("Data yang dipaparkan:")
add_bullet("Jumlah peserta berdaftar vs. jumlah yang hadir")
add_bullet("Pecahan mengikut sesi (Slot masa check-in)")
add_bullet("Pecahan mengikut sumber pendaftaran (Pra-daftar / Walk-in)")
add_bullet("Status sijil (Dijana, Dihantar, Dimuat Turun)")
add_bullet("Keputusan soalselidik (jika soalselidik dikaitkan)")
add_screenshot_box(
"Halaman Statistik — Graf dan data analitik program",
f"{BASE_URL}/admin/programs/{{uuid}}/statistics"
)
add_body("Klik butang Export Excel untuk memuat turun laporan statistik dalam format Excel.")
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 11: SET SOALSELIDIK
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("11. Set Soalselidik")
add_body(
"Modul Set Soalselidik membolehkan pentadbir membina borang soalan yang boleh digunakan semula "
"merentasi pelbagai program."
)
add_screenshot_box(
"Senarai Set Soalselidik — Semua set yang telah dicipta",
f"{BASE_URL}/admin/questionnaires"
)
# 11.1
add_heading("11.1 Cipta Set Soalselidik", level=2)
add_body("Langkah-langkah mencipta set soalselidik baru:")
add_bullet("Klik butang + Cipta Set Soalselidik.")
add_bullet("Masukkan Nama Set dan Penerangan (pilihan).")
add_bullet("Klik Simpan.")
add_screenshot_box(
"Borang Cipta Set Soalselidik — Nama dan penerangan",
f"{BASE_URL}/admin/questionnaires/create"
)
# 11.2
add_heading("11.2 Tambah dan Urus Soalan", level=2)
add_body("Selepas set dicipta, tambah soalan melalui halaman butiran set soalselidik.")
add_body("Jenis soalan yang tersedia:")
jenis_soalan = [
("Tajuk (Seksyen)", "Pengepala bahagian — boleh menjadi parent kepada soalan Rating"),
("Rating (1-5)", "Penilaian skala 1 hingga 5 — mesti diletakkan di bawah Tajuk"),
("Pilihan Tunggal", "Peserta pilih satu jawapan sahaja"),
("Pilihan Berganda", "Peserta boleh pilih lebih dari satu jawapan"),
("Teks Pendek", "Jawapan dalam satu baris"),
("Teks Panjang", "Jawapan berbilang baris"),
]
for jenis, desc in jenis_soalan:
p = doc.add_paragraph()
p.paragraph_format.left_indent = Cm(1)
r1 = p.add_run(f"{jenis}: ")
r1.bold = True
r1.font.size = Pt(11)
p.add_run(desc).font.size = Pt(11)
add_screenshot_box(
"Halaman Set Soalselidik — Senarai soalan dan borang tambah soalan",
f"{BASE_URL}/admin/questionnaires/{{id}}"
)
add_body("Ciri-ciri tambahan:")
add_bullet("Soalan Rating: Pentadbir boleh tetapkan label teks untuk setiap nilai (1-5) pada peringkat Tajuk.")
add_bullet("Susunan soalan boleh diubah dengan seret-dan-lepas (drag-and-drop).")
add_bullet("Soalan Rating tidak boleh dipindahkan keluar dari Tajuk induknya.")
add_bullet("Tetapkan soalan sebagai Wajib atau tidak wajib.")
add_note(
"Set soalselidik perlu di-Publish sebelum boleh dikaitkan dengan program. "
"Selepas di-Publish, soalan tidak boleh diubah."
)
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 12: PENGURUSAN PENGGUNA (SUPER ADMIN)
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("12. Pengurusan Pengguna")
add_body(
"Modul ini hanya boleh diakses oleh Super Admin. "
"Super Admin boleh mencipta dan mengurus akaun pentadbir lain dalam sistem."
)
add_body("Fungsi yang tersedia:")
add_bullet("Lihat senarai semua pentadbir")
add_bullet("Cipta akaun pentadbir baru")
add_bullet("Kemaskini maklumat pentadbir")
add_bullet("Padam akaun pentadbir")
add_screenshot_box(
"Halaman Pengurusan Pengguna — Senarai pentadbir (Super Admin sahaja)",
f"{BASE_URL}/admin/users"
)
add_body("Jenis peranan pengguna:")
add_bullet("Super Admin — Akses penuh termasuk Pengurusan Pengguna")
add_bullet("Admin Program — Akses kepada semua program dan soalselidik, kecuali Pengurusan Pengguna")
add_note("Setiap sistem perlu sekurang-kurangnya satu akaun Super Admin yang aktif.")
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# BAB 13: PROFIL PENGGUNA
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("13. Profil Pengguna")
add_body(
"Setiap pentadbir boleh mengurus maklumat akaun peribadi mereka melalui halaman Profil. "
"Klik nama pengguna atau butang Profil di bar navigasi kiri untuk mengakses halaman ini."
)
add_body("Fungsi yang tersedia:")
add_heading("Tukar Alamat Emel", level=2)
add_bullet("Masukkan Kata Laluan Semasa untuk pengesahan.")
add_bullet("Masukkan Emel Baru.")
add_bullet("Klik Kemaskini Emel.")
add_heading("Tukar Kata Laluan", level=2)
add_bullet("Masukkan Kata Laluan Semasa.")
add_bullet("Masukkan Kata Laluan Baru (minimum 8 aksara).")
add_bullet("Masukkan semula Kata Laluan Baru untuk pengesahan.")
add_bullet("Klik Tukar Kata Laluan.")
add_screenshot_box(
"Halaman Profil — Tukar emel dan kata laluan",
f"{BASE_URL}/admin/profile"
)
add_note("Kata laluan baru mestilah sekurang-kurangnya 8 aksara. Simpan kata laluan di tempat yang selamat.")
page_break()
# ═══════════════════════════════════════════════════════════════════════════════
# LAMPIRAN: ALIRAN KERJA SISTEM
# ═══════════════════════════════════════════════════════════════════════════════
add_heading("Lampiran: Aliran Kerja Tipikal")
add_body("Berikut adalah urutan langkah yang disyorkan untuk menjalankan sebuah program dari mula hingga selesai:")
workflow = [
("1", "Cipta Set Soalselidik", "Bina soalan maklum balas di Modul Set Soalselidik dan publish."),
("2", "Cipta Program", "Isi maklumat program, tarikh, dan tetapan check-in."),
("3", "Import Peserta", "Muat naik senarai peserta melalui Excel."),
("4", "Muat Naik Template Sijil", "Upload reka bentuk sijil dan konfigurasi kedudukan teks."),
("5", "Lampirkan Soalselidik", "Kaitkan set soalselidik dengan program dan sahkan."),
("6", "Publish Program", "Aktifkan program supaya peserta boleh check-in."),
("7", "Jana & Papar Kod QR", "Paparkan QR Code semasa acara untuk peserta mengimbas."),
("8", "Pantau Statistik", "Semak kehadiran dan maklum balas dalam masa nyata."),
("9", "Jana dan Hantar Sijil", "Selepas program tamat, jana sijil dan hantar melalui emel."),
]
tbl = doc.add_table(rows=1, cols=3)
tbl.style = 'Table Grid'
hdr = tbl.rows[0].cells
hdr[0].text = "Langkah"
hdr[1].text = "Tindakan"
hdr[2].text = "Penerangan"
for cell in hdr:
cell.paragraphs[0].runs[0].bold = True
cell.paragraphs[0].runs[0].font.size = Pt(10)
for step, action, desc in workflow:
row = tbl.add_row().cells
row[0].text = step
row[1].text = action
row[2].text = desc
for cell in row:
cell.paragraphs[0].runs[0].font.size = Pt(10)
doc.add_paragraph()
add_body(f"Untuk sokongan teknikal, hubungi pentadbir sistem atau lawati: {BASE_URL}")
# ── Simpan dokumen ─────────────────────────────────────────────────────────────
output_path = r"C:\Users\User\Aplikasi\ecert\manual\Manual_Pengguna_eCert_MBIP.docx"
doc.save(output_path)
print(f"Manual berjaya dijana: {output_path}")

2046
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,294 +0,0 @@
@extends('layouts.admin')
@section('title', $set->title)
@section('header', $set->title)
@section('breadcrumb')
<li class="breadcrumb-item"><a href="{{ route('admin.questionnaires.index') }}">Soalselidik</a></li>
<li class="breadcrumb-item active">{{ Str::limit($set->title, 35) }}</li>
@endsection
@section('header-actions')
<div class="d-flex gap-2">
<a href="{{ route('admin.questionnaires.edit', $set) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i> Edit
</a>
@if($set->status === 'draft')
<form method="POST" action="{{ route('admin.questionnaires.publish', $set) }}">
@csrf
<button class="btn btn-sm btn-success" onclick="return confirm('Terbitkan set soalselidik ini?')">
<i class="bi bi-send me-1"></i> Terbitkan
</button>
</form>
@elseif($set->status === 'published')
<form method="POST" action="{{ route('admin.questionnaires.archive', $set) }}">
@csrf
<button class="btn btn-sm btn-outline-secondary" onclick="return confirm('Arkibkan set soalselidik ini?')">
<i class="bi bi-archive me-1"></i> Arkib
</button>
</form>
@endif
</div>
@endsection
@section('content')
<div class="row g-4">
{{-- Left: Questions --}}
<div class="col-md-8">
{{-- Status Banner --}}
@if($set->status === 'draft')
<div class="alert alert-warning mb-3 small">
<i class="bi bi-exclamation-triangle me-2"></i>
Set ini masih dalam status <strong>Draf</strong>. Terbitkan setelah siap untuk dilampirkan ke program.
</div>
@elseif($set->status === 'archived')
<div class="alert alert-secondary mb-3 small">
<i class="bi bi-archive me-2"></i>
Set ini telah <strong>diarkibkan</strong> dan tidak boleh dilampirkan ke program baru.
</div>
@endif
{{-- Question List --}}
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white d-flex justify-content-between align-items-center py-3">
<h6 class="mb-0 fw-semibold">
<i class="bi bi-list-ul me-2 text-primary"></i>Senarai Soalan
<span class="badge bg-secondary ms-2">{{ $set->questions->count() }}</span>
</h6>
</div>
@if($set->questions->isEmpty())
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-question-circle d-block fs-1 mb-3 opacity-25"></i>
Belum ada soalan. Tambah soalan menggunakan borang di sebelah kanan.
</div>
@else
<ul class="list-group list-group-flush" id="questionList">
@foreach($set->questions as $q)
<li class="list-group-item py-3" data-id="{{ $q->id }}">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-light text-dark border small">{{ $loop->iteration }}</span>
<span class="badge bg-primary bg-opacity-10 text-primary small">
{{ match($q->question_type) {
'rating' => 'Rating',
'single_choice' => 'Pilihan Tunggal',
'multiple_choice' => 'Pilihan Berganda',
'short_text' => 'Teks Pendek',
'long_text' => 'Teks Panjang',
default => $q->question_type,
} }}
</span>
@if($q->is_required)
<span class="badge bg-danger bg-opacity-10 text-danger small">Wajib</span>
@endif
</div>
<div class="fw-medium">{{ $q->question_text }}</div>
@if($q->options_json)
<div class="mt-1">
@foreach($q->options_json as $opt)
<span class="badge bg-light text-dark border me-1 small">{{ $opt }}</span>
@endforeach
</div>
@endif
</div>
<div class="d-flex gap-1 flex-shrink-0">
<button class="btn btn-sm btn-outline-secondary"
onclick="editQuestion({{ $q->id }}, @json($q->question_text), '{{ $q->question_type }}', {{ $q->is_required ? 'true' : 'false' }}, @json($q->options_json ?? []))">
<i class="bi bi-pencil"></i>
</button>
<form method="POST" action="{{ route('admin.questions.destroy', $q) }}"
onsubmit="return confirm('Padam soalan ini?')">
@csrf @method('DELETE')
<button class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
</li>
@endforeach
</ul>
@endif
</div>
{{-- Used In Programs --}}
@if($usedInPrograms->count())
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-3">
<h6 class="mb-0 fw-semibold"><i class="bi bi-diagram-3 me-2 text-secondary"></i>Digunakan Dalam Program</h6>
</div>
<ul class="list-group list-group-flush">
@foreach($usedInPrograms as $program)
<li class="list-group-item d-flex justify-content-between align-items-center py-2">
<a href="{{ route('admin.programs.show', $program) }}" class="text-decoration-none small">
{{ $program->title }}
</a>
@if($program->pivot->is_confirmed ?? false)
<span class="badge bg-success">Disahkan</span>
@else
<span class="badge bg-warning text-dark">Belum Disahkan</span>
@endif
</li>
@endforeach
</ul>
</div>
@endif
</div>
{{-- Right: Add / Edit Question Form --}}
<div class="col-md-4">
<div class="card border-0 shadow-sm sticky-top" style="top:80px;">
<div class="card-header bg-white py-3">
<h6 class="mb-0 fw-semibold" id="formTitle"><i class="bi bi-plus-circle me-2 text-primary"></i>Tambah Soalan</h6>
</div>
<div class="card-body">
{{-- Add Question Form --}}
<form method="POST" id="questionForm" action="{{ route('admin.questions.store', $set) }}">
@csrf
<input type="hidden" name="_method" id="formMethod" value="POST">
<input type="hidden" name="_question_id" id="questionId" value="">
<div class="mb-3">
<label class="form-label small fw-medium">Soalan <span class="text-danger">*</span></label>
<textarea name="question_text" id="questionText" rows="3"
class="form-control form-control-sm @error('question_text') is-invalid @enderror"
placeholder="Taip soalan di sini..."></textarea>
@error('question_text')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label class="form-label small fw-medium">Jenis Soalan <span class="text-danger">*</span></label>
<select name="question_type" id="questionType" class="form-select form-select-sm" onchange="toggleOptions()">
<option value="rating">Rating (15)</option>
<option value="single_choice">Pilihan Tunggal</option>
<option value="multiple_choice">Pilihan Berganda</option>
<option value="short_text">Teks Pendek</option>
<option value="long_text">Teks Panjang</option>
</select>
</div>
<div class="mb-3 form-check">
<input type="checkbox" name="is_required" id="isRequired" class="form-check-input" value="1" checked>
<label class="form-check-label small" for="isRequired">Wajib dijawab</label>
</div>
<div id="optionsSection" class="mb-3 d-none">
<label class="form-label small fw-medium">Pilihan Jawapan</label>
<div id="optionsList">
<div class="input-group input-group-sm mb-2">
<input type="text" name="options[]" class="form-control" placeholder="Pilihan 1">
<button type="button" class="btn btn-outline-danger" onclick="removeOption(this)"><i class="bi bi-x"></i></button>
</div>
<div class="input-group input-group-sm mb-2">
<input type="text" name="options[]" class="form-control" placeholder="Pilihan 2">
<button type="button" class="btn btn-outline-danger" onclick="removeOption(this)"><i class="bi bi-x"></i></button>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary w-100" onclick="addOption()">
<i class="bi bi-plus me-1"></i> Tambah Pilihan
</button>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-check-lg me-1"></i> <span id="submitLabel">Tambah</span>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="cancelEdit" style="display:none;" onclick="resetForm()">
Batal
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
const setId = {{ $set->id }};
const storeUrl = "{{ route('admin.questions.store', $set) }}";
const updateBase = "{{ url('admin/questions') }}/";
function toggleOptions() {
const type = document.getElementById('questionType').value;
const section = document.getElementById('optionsSection');
section.classList.toggle('d-none', !['single_choice','multiple_choice'].includes(type));
}
function addOption() {
const list = document.getElementById('optionsList');
const count = list.querySelectorAll('input').length + 1;
const div = document.createElement('div');
div.className = 'input-group input-group-sm mb-2';
div.innerHTML = `<input type="text" name="options[]" class="form-control" placeholder="Pilihan ${count}">
<button type="button" class="btn btn-outline-danger" onclick="removeOption(this)"><i class="bi bi-x"></i></button>`;
list.appendChild(div);
}
function removeOption(btn) {
const list = document.getElementById('optionsList');
if (list.querySelectorAll('.input-group').length > 1) {
btn.closest('.input-group').remove();
}
}
function editQuestion(id, text, type, required, options) {
document.getElementById('formTitle').innerHTML = '<i class="bi bi-pencil me-2 text-warning"></i>Edit Soalan';
document.getElementById('submitLabel').textContent = 'Kemaskini';
document.getElementById('cancelEdit').style.display = '';
document.getElementById('questionId').value = id;
document.getElementById('questionText').value = text;
document.getElementById('questionType').value = type;
document.getElementById('isRequired').checked = required;
const form = document.getElementById('questionForm');
form.action = updateBase + id;
document.getElementById('formMethod').value = 'PUT';
toggleOptions();
const list = document.getElementById('optionsList');
list.innerHTML = '';
if (options && options.length) {
options.forEach((opt, i) => {
const div = document.createElement('div');
div.className = 'input-group input-group-sm mb-2';
div.innerHTML = `<input type="text" name="options[]" class="form-control" value="${opt}" placeholder="Pilihan ${i+1}">
<button type="button" class="btn btn-outline-danger" onclick="removeOption(this)"><i class="bi bi-x"></i></button>`;
list.appendChild(div);
});
}
form.scrollIntoView({ behavior: 'smooth' });
}
function resetForm() {
document.getElementById('formTitle').innerHTML = '<i class="bi bi-plus-circle me-2 text-primary"></i>Tambah Soalan';
document.getElementById('submitLabel').textContent = 'Tambah';
document.getElementById('cancelEdit').style.display = 'none';
document.getElementById('questionForm').reset();
document.getElementById('questionForm').action = storeUrl;
document.getElementById('formMethod').value = 'POST';
document.getElementById('questionId').value = '';
document.getElementById('optionsSection').classList.add('d-none');
const list = document.getElementById('optionsList');
list.innerHTML = `<div class="input-group input-group-sm mb-2">
<input type="text" name="options[]" class="form-control" placeholder="Pilihan 1">
<button type="button" class="btn btn-outline-danger" onclick="removeOption(this)"><i class="bi bi-x"></i></button>
</div>
<div class="input-group input-group-sm mb-2">
<input type="text" name="options[]" class="form-control" placeholder="Pilihan 2">
<button type="button" class="btn btn-outline-danger" onclick="removeOption(this)"><i class="bi bi-x"></i></button>
</div>`;
}
</script>
@endpush

View File

@@ -28,7 +28,9 @@ LOG_LEVEL=debug
# ── Database ──────────────────────────────────────────────────────────────────
DB_CONNECTION=mysql
DB_HOST=db # nama service dalam docker-compose.yml
# DEV (Windows): DB_HOST=host.docker.internal ← MySQL pada host Windows
# PRODUCTION: DB_HOST=172.17.200.16 ← MySQL server external
DB_HOST=host.docker.internal
DB_PORT=3306
DB_DATABASE=ecert_mbip
DB_USERNAME=ecert

31
src/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
# Application-specific storage (private files)
/storage/app/private/certificates/
/storage/app/private/imports/
/storage/app/public/qrcodes/

View File

View File

@@ -8,6 +8,8 @@ use App\Models\Certificate;
use App\Models\Program;
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
@@ -97,6 +99,31 @@ class CertificateController extends Controller
return back()->with('success', "Penghantaran emel sijil dijadualkan untuk {$toEmail} peserta.");
}
public function download(Program $program, Certificate $certificate): Response|RedirectResponse
{
if ($certificate->program_id !== $program->id) {
abort(404);
}
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.');
}
$certificate->loadMissing('participant');
$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),
]);
}
private function buildCertNo(Program $program, int $seq): string
{
$year = now()->format('Y');

View File

@@ -55,13 +55,20 @@ class CertificateTemplateController extends Controller
'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);
$config = $template->config_json ?? [];
$merged = array_merge($config['fields'] ?? [], $request->fields);
// Kalau toggle No. Sijil dimatikan, buang dari config
if (! $request->boolean('show_cert_no')) {
unset($merged['certificate_no']);
}
$config['fields'] = $merged;
$template->update(['config_json' => $config]);
return redirect()->route('admin.programs.template.show', $program)
@@ -102,8 +109,19 @@ class CertificateTemplateController extends Controller
$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);
$imageData = $service->generatePreview($template, $sampleName, $sampleNo, $liveFields);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()], 500);
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use App\Models\Certificate;
use App\Models\Participant;
use App\Models\Program;
use App\Models\QuestionnaireResponse;
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller
{
public function index()
{
// Sijil dijana tapi belum dihantar emel (ada emel peserta)
$emailsPending = DB::table('certificates')
->join('participants', 'participants.id', '=', 'certificates.participant_id')
->where('certificates.status', 'generated')
->whereNull('certificates.emailed_at')
->whereNotNull('participants.email')
->count();
$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_download_count' => (int) Certificate::sum('download_count'),
'total_responses' => QuestionnaireResponse::count(),
'emails_pending' => $emailsPending,
'emails_sent' => Certificate::whereNotNull('emailed_at')->count(),
'emails_failed' => DB::table('program_participants')->where('status_sent_emel', 'failed')->count(),
];
$recentPrograms = Program::with('creator')
->latest()
->limit(5)
->get();
return view('admin.dashboard', compact('stats', 'recentPrograms'));
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Certificate;
use App\Models\Participant;
use App\Models\Program;
use App\Models\ProgramParticipant;
@@ -40,14 +41,26 @@ class ParticipantController extends Controller
$programParticipants = $query->paginate(20)->withQueryString();
// Load certificates for displayed participants
$participantIds = $programParticipants->pluck('participant_id');
$certificates = Certificate::where('program_id', $program->id)
->whereIn('participant_id', $participantIds)
->get()
->keyBy('participant_id');
$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' => $program->programParticipants()->count(),
'pre_registered' => $program->programParticipants()->where('is_pre_registered', true)->count(),
'walk_in' => $program->programParticipants()->where('registration_source', 'walk_in')->count(),
'checked_in' => $program->programParticipants()->where('status', 'checked_in')->count(),
'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'));
return view('admin.programs.participants.index', compact('program', 'programParticipants', 'counts', 'certificates'));
}
public function create(Program $program): View
@@ -99,6 +112,58 @@ class ParticipantController extends Controller
return back()->with('success', 'Peserta berjaya ditambah.');
}
public function edit(Program $program, ProgramParticipant $pp, Request $request): View
{
if ($pp->program_id !== $program->id) {
abort(403);
}
$pp->load('participant');
$filters = $request->only(['search', 'source', 'status', 'page']);
return view('admin.programs.participants.edit', compact('program', 'pp', 'filters'));
}
public function update(Program $program, ProgramParticipant $pp, Request $request): RedirectResponse
{
if ($pp->program_id !== $program->id) {
abort(403);
}
$request->validate([
'name' => ['required', 'string', 'max:255'],
'no_kp' => ['required', 'string', 'regex:/^\d{12}$/', 'unique:participants,no_kp,' . $pp->participant_id],
'email' => ['nullable', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
'agency' => ['nullable', 'string', 'max:255'],
'session' => ['nullable', 'in:pagi,petang,full_day'],
]);
$pp->load('participant');
DB::transaction(function () use ($pp, $request) {
$pp->participant->update([
'name' => $request->name,
'no_kp' => preg_replace('/[^0-9]/', '', $request->no_kp),
'email' => $request->email ?: null,
'phone' => $request->phone ?: null,
'agency' => $request->agency ?: null,
]);
$pp->update([
'pre_registered_session' => $request->session ?: null,
]);
});
AuditLogService::log('participant.updated', $pp->participant);
$filters = array_filter($request->only(['search', 'source', 'status', 'page']));
$indexUrl = route('admin.programs.participants.index', $program)
. ($filters ? '?' . http_build_query($filters) : '');
return redirect($indexUrl)->with('success', 'Maklumat peserta berjaya dikemaskini.');
}
public function destroy(Program $program, ProgramParticipant $pp): RedirectResponse
{
if ($pp->program_id !== $program->id) {
@@ -116,31 +181,52 @@ class ParticipantController extends Controller
public function importForm(Program $program): View
{
return view('admin.programs.participants.import', compact('program'));
$cutoff = $program->checkin_end_at ?? $program->end_date->endOfDay();
$programEnded = now()->gt($cutoff);
return view('admin.programs.participants.import', compact('program', 'programEnded'));
}
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'],
'csv_file' => ['required', 'file', 'mimes:csv,txt', 'max:5120'],
'session' => ['nullable', 'in:pagi,petang,full_day'],
'mark_attendance' => ['nullable', 'boolean'],
]);
$cutoff = $program->checkin_end_at ?? $program->end_date->endOfDay();
$markAttendance = now()->gt($cutoff) && $request->boolean('mark_attendance');
$result = $importer->import(
$program,
$request->file('csv_file'),
$request->input('session', $program->default_staff_session)
$request->input('session', $program->default_staff_session),
$markAttendance
);
AuditLogService::log('participant.imported', $program, [], [
'success' => $result['success'],
'duplicates' => $result['duplicates'],
'failed' => $result['failed'],
'success' => $result['success'],
'duplicates' => $result['duplicates'],
'failed' => $result['failed'],
'mark_attendance'=> $markAttendance,
]);
return back()->with('import_result', $result);
}
public function clearParticipants(Program $program): RedirectResponse
{
$deleted = $program->programParticipants()
->where('status', '!=', 'checked_in')
->whereDoesntHave('attendance')
->delete();
return redirect()
->route('admin.programs.participants.import.form', $program)
->with('success', "{$deleted} rekod peserta (belum hadir) telah dipadam.");
}
public function export(Program $program): \Symfony\Component\HttpFoundation\StreamedResponse
{
$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,221 @@
<?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\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
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, SUM(download_count) as total_downloads, SUM(status = 'downloaded') as downloaded")
->first();
// Sijil dijana tapi belum dihantar emel (ada emel peserta)
$emailsPending = \DB::table('certificates')
->join('participants', 'participants.id', '=', 'certificates.participant_id')
->where('certificates.program_id', $program->id)
->where('certificates.status', 'generated')
->whereNull('certificates.emailed_at')
->whereNotNull('participants.email')
->count();
$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),
'downloaded_certificates'=> (int) ($certStats->downloaded ?? 0),
'total_downloads' => (int) ($certStats->total_downloads ?? 0),
'emails_pending' => $emailsPending,
'emails_sent' => (int) \DB::table('program_participants')->where('program_id', $program->id)->where('status_sent_emel', 'sent')->count(),
'emails_failed' => (int) \DB::table('program_participants')->where('program_id', $program->id)->where('status_sent_emel', 'failed')->count(),
];
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);
// Capture audit data before deletion
$auditData = [
'program_title' => $program->title,
'start_date' => $program->start_date?->toDateString(),
'end_date' => $program->end_date?->toDateString(),
'program_created_at' => $program->created_at?->toDateTimeString(),
'deleted_by' => auth()->user()->name,
];
// Collect file paths before transaction
$certFiles = $program->certificates()->pluck('file_path')->filter()->values()->all();
$templateFiles = $program->certificateTemplate ? [$program->certificateTemplate->image_path] : [];
$qrFiles = $program->qrCode ? [$program->qrCode->qr_image_path] : [];
$title = $program->title;
AuditLogService::log('program.deleted', $program, [], $auditData);
DB::transaction(function () use ($program) {
$programId = $program->id;
DB::table('email_logs')->where('program_id', $programId)->delete();
// questionnaire_answers has FK to questionnaire_responses
DB::table('questionnaire_answers')
->whereIn('questionnaire_response_id', function ($q) use ($programId) {
$q->select('id')->from('questionnaire_responses')->where('program_id', $programId);
})
->delete();
DB::table('questionnaire_responses')->where('program_id', $programId)->delete();
DB::table('program_questionnaires')->where('program_id', $programId)->delete();
DB::table('certificates')->where('program_id', $programId)->delete();
DB::table('attendances')->where('program_id', $programId)->delete();
DB::table('program_participants')->where('program_id', $programId)->delete();
DB::table('certificate_templates')->where('program_id', $programId)->delete();
DB::table('program_qr_codes')->where('program_id', $programId)->delete();
$program->delete();
});
// Delete physical files after transaction
foreach (array_merge($certFiles, $templateFiles, $qrFiles) as $path) {
if ($path) {
Storage::disk('local')->delete($path);
}
}
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

@@ -69,6 +69,23 @@ class ProgramQuestionnaireController extends Controller
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;

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

@@ -52,10 +52,18 @@ class QuestionnaireSetController extends Controller
public function show(QuestionnaireSet $set): View
{
$set->load(['questions', 'creator']);
$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', 'usedInPrograms'));
return view('admin.questionnaires.show', compact('set', 'topLevel', 'totalCount', 'usedInPrograms'));
}
public function edit(QuestionnaireSet $set): View

View File

@@ -17,7 +17,7 @@ class StatisticsController extends Controller
{
public function show(Program $program): View
{
$program->load(['attendances.participant', 'questionnaire.questionnaireSet.questions']);
$program->load(['questionnaire.questionnaireSet.questions']);
// Attendance by session
$bySession = $program->attendances()
@@ -40,23 +40,29 @@ class StatisticsController extends Controller
->pluck('total', 'status')
->toArray();
// Response rate
// Response rate + question stats
$pq = $program->questionnaire;
$responseRate = null;
$questionStats = [];
$totalResponses = 0;
if ($pq && $pq->is_confirmed) {
$totalAttended = $program->attendances()->count();
$totalAttended = array_sum($bySession); // reuse already-fetched data
$totalResponses = QuestionnaireResponse::where('program_id', $program->id)->count();
$responseRate = $totalAttended > 0 ? round($totalResponses / $totalAttended * 100) : 0;
// Rating question averages
$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') {
$answers = QuestionnaireAnswer::where('questionnaire_question_id', $q->id)
->pluck('answer_value');
$values = $answers->map(fn($v) => is_array($v) ? (int)($v[0] ?? 0) : (int)$v);
$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,
@@ -65,11 +71,9 @@ class StatisticsController extends Controller
'count' => $values->count(),
];
} elseif (in_array($q->question_type, ['single_choice', 'multiple_choice'])) {
$answers = QuestionnaireAnswer::where('questionnaire_question_id', $q->id)
->pluck('answer_value');
$counts = [];
foreach ($answers as $val) {
$items = is_array($val) ? $val : [$val];
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;
}
@@ -86,14 +90,15 @@ class StatisticsController extends Controller
}
}
// Reuse data already computed above — no extra queries
$summary = [
'total_attendances' => $program->attendances()->count(),
'pre_registered' => $program->attendances()->where('attendance_source', 'pre_registered_staff')->count(),
'walk_in' => $program->attendances()->where('attendance_source', 'walk_in_external')->count(),
'total_certificates' => $program->certificates()->count(),
'generated_certs' => $program->certificates()->whereIn('status', ['generated', 'emailed', 'downloaded'])->count(),
'downloaded_certs' => $program->certificates()->where('status', 'downloaded')->count(),
'total_responses' => QuestionnaireResponse::where('program_id', $program->id)->count(),
'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(

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Jobs\SendCertificateEmailJob;
use App\Models\Certificate;
use App\Models\Participant;
use App\Models\ProgramQrCode;
@@ -57,4 +58,35 @@ class AttendanceCheckController extends Controller
return view('public.semak.result', compact('program', 'qrCode', 'participant', 'attendance', 'certificate'))
->with('found', (bool) $attendance);
}
public function updateEmail(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'],
'email' => ['required', 'email', 'max:255'],
], [
'email.required' => 'Sila masukkan alamat emel.',
'email.email' => 'Format emel tidak sah.',
]);
$participant = Participant::where('no_kp', $request->no_kp)->firstOrFail();
$participant->update(['email' => $request->email]);
$attendance = $participant->attendanceForProgram($program->id);
$certificate = Certificate::where('program_id', $program->id)
->where('participant_id', $participant->id)
->first();
// Masukkan queue hantar e-sijil jika sijil sudah dijana dan belum dihantar
if ($certificate && $certificate->status === 'generated' && ! $certificate->emailed_at) {
SendCertificateEmailJob::dispatchForCert($certificate);
}
return view('public.semak.result', compact('program', 'qrCode', 'participant', 'attendance', 'certificate'))
->with('found', true)
->with('email_updated', true);
}
}

View File

@@ -50,8 +50,11 @@ class CheckinController extends Controller
$request->validate([
'no_kp' => ['required', 'string', 'max:20'],
'email' => ['nullable', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
], [
'no_kp.required' => 'Sila masukkan No. Kad Pengenalan anda.',
'email.email' => 'Format emel tidak sah.',
]);
$result = $this->attendanceService->staffCheckin($program, $request->no_kp, $request);

View File

@@ -9,6 +9,7 @@ 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
@@ -20,27 +21,20 @@ class QuestionnaireController extends Controller
$participant = Participant::where('uuid', $participant_uuid)->firstOrFail();
// Verify participant belongs to this program
$pp = $program->programParticipants()->where('participant_id', $participant->id)->first();
abort_if(! $pp, 404);
$pq = $program->questionnaire()->with('questionnaireSet.questions')->first();
$pq = $program->questionnaire()->with('questionnaireSet')->first();
if (! $pq || ! $pq->is_confirmed) {
// No questionnaire — go straight to semak page
return redirect()->route('public.semak.show', $qr_token);
}
// Check already submitted
$alreadySubmitted = QuestionnaireResponse::where('program_id', $program->id)
->where('participant_id', $participant->id)
->exists();
if ($alreadySubmitted) {
if (QuestionnaireResponse::where('program_id', $program->id)->where('participant_id', $participant->id)->exists()) {
return view('public.questionnaire.already', compact('program', 'participant', 'qrCode'));
}
$questions = $pq->questionnaireSet->questions;
$questions = $this->loadHierarchical($pq);
return view('public.questionnaire.show', compact('program', 'participant', 'qrCode', 'pq', 'questions'));
}
@@ -55,38 +49,27 @@ class QuestionnaireController extends Controller
$pp = $program->programParticipants()->where('participant_id', $participant->id)->first();
abort_if(! $pp, 404);
$pq = $program->questionnaire()->with('questionnaireSet.questions')->first();
$pq = $program->questionnaire()->with('questionnaireSet')->first();
abort_if(! $pq || ! $pq->is_confirmed, 404);
// Prevent double-submit
$existing = QuestionnaireResponse::where('program_id', $program->id)
->where('participant_id', $participant->id)
->first();
if ($existing) {
if (QuestionnaireResponse::where('program_id', $program->id)->where('participant_id', $participant->id)->exists()) {
return view('public.questionnaire.already', compact('program', 'participant', 'qrCode'));
}
$questions = $pq->questionnaireSet->questions;
$questions = $this->loadHierarchical($pq);
$answerable = $this->flatten($questions);
// Validate required questions
$rules = [];
foreach ($questions as $q) {
if ($q->is_required) {
$rules['q_' . $q->id] = 'required';
} else {
$rules['q_' . $q->id] = 'nullable';
}
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';
}
}
$validated = $request->validate($rules, [
'q_*.required' => 'Soalan ini wajib dijawab.',
]);
$request->validate($rules, ['q_*.required' => 'Soalan ini wajib dijawab.']);
// Save response
$response = QuestionnaireResponse::create([
'program_id' => $program->id,
'participant_id' => $participant->id,
@@ -96,7 +79,7 @@ class QuestionnaireController extends Controller
'user_agent' => substr($request->userAgent() ?? '', 0, 500),
]);
foreach ($questions as $q) {
foreach ($answerable as $q) {
$raw = $request->input('q_' . $q->id);
if ($raw === null && ! $q->is_required) {
@@ -110,12 +93,39 @@ class QuestionnaireController extends Controller
};
QuestionnaireAnswer::create([
'questionnaire_response_id' => $response->id,
'questionnaire_question_id' => $q->id,
'answer_value' => $value,
'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,135 @@
<?php
namespace App\Jobs;
use App\Mail\CertificateReadyMail;
use App\Models\Certificate;
use App\Models\EmailLog;
use App\Models\Program;
use App\Models\ProgramParticipant;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendCertificateEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(public readonly Certificate $certificate) {}
public function handle(): void
{
$cert = $this->certificate->refresh();
$cert->load(['participant', 'program']);
$email = $cert->participant->email;
if (! $email) {
$this->updatePpStatus($cert, null);
EmailLog::where('certificate_id', $cert->id)->where('status', 'pending')->delete();
return;
}
$log = EmailLog::where('certificate_id', $cert->id)
->where('status', 'pending')
->latest()
->first();
try {
Mail::to($email)->send(new CertificateReadyMail($cert));
$cert->update(['status' => 'emailed', 'emailed_at' => now()]);
$this->updatePpStatus($cert, 'sent');
if ($log) {
$log->update(['status' => 'sent', 'sent_at' => now()]);
} else {
EmailLog::create([
'program_id' => $cert->program_id,
'participant_id' => $cert->participant_id,
'certificate_id' => $cert->id,
'recipient_email' => $email,
'subject' => 'Sijil Digital Program — ' . $cert->program->title,
'email_type' => 'certificate_ready',
'status' => 'sent',
'sent_at' => now(),
]);
}
} catch (\Throwable $e) {
$this->updatePpStatus($cert, 'failed');
if ($log) {
$log->update(['status' => 'failed', 'error_message' => $e->getMessage()]);
} else {
EmailLog::create([
'program_id' => $cert->program_id,
'participant_id' => $cert->participant_id,
'certificate_id' => $cert->id,
'recipient_email' => $email,
'subject' => 'Sijil Digital Program — ' . $cert->program->title,
'email_type' => 'certificate_ready',
'status' => 'failed',
'error_message' => $e->getMessage(),
]);
}
throw $e;
}
}
/**
* Cipta pending EmailLog dan set status_sent_emel = pending, kemudian dispatch job.
*/
public static function dispatchForCert(Certificate $cert): void
{
$cert->loadMissing(['participant', 'program']);
self::updatePpStatusStatic($cert, 'pending');
EmailLog::create([
'program_id' => $cert->program_id,
'participant_id' => $cert->participant_id,
'certificate_id' => $cert->id,
'recipient_email' => $cert->participant->email,
'subject' => 'Sijil Digital Program — ' . $cert->program->title,
'email_type' => 'certificate_ready',
'status' => 'pending',
]);
static::dispatch($cert);
}
public static function dispatchBatch(Program $program): void
{
$program->certificates()
->whereIn('status', ['generated'])
->whereNull('emailed_at')
->with(['participant', 'program'])
->each(function (Certificate $cert) {
if ($cert->participant->email) {
static::dispatchForCert($cert);
}
});
}
private function updatePpStatus(Certificate $cert, ?string $status): void
{
ProgramParticipant::where('program_id', $cert->program_id)
->where('participant_id', $cert->participant_id)
->update(['status_sent_emel' => $status]);
}
private static function updatePpStatusStatic(Certificate $cert, ?string $status): void
{
ProgramParticipant::where('program_id', $cert->program_id)
->where('participant_id', $cert->participant_id)
->update(['status_sent_emel' => $status]);
}
}

View File

@@ -9,7 +9,7 @@ class ProgramParticipant extends Model
protected $fillable = [
'program_id', 'participant_id',
'registration_source', 'is_pre_registered', 'pre_registered_session',
'status', 'registered_at',
'status', 'status_sent_emel', 'registered_at',
];
protected function casts(): array

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class QuestionnaireQuestion extends Model
{
protected $fillable = [
'questionnaire_set_id', 'parent_id', 'question_text', 'question_type',
'options_json', 'rating_labels', 'is_required', 'sort_order',
];
protected function casts(): array
{
return [
'options_json' => 'array',
'rating_labels' => 'array',
'is_required' => 'boolean',
'sort_order' => 'integer',
];
}
public function questionnaireSet()
{
return $this->belongsTo(QuestionnaireSet::class);
}
public function parent()
{
return $this->belongsTo(QuestionnaireQuestion::class, 'parent_id');
}
public function children()
{
return $this->hasMany(QuestionnaireQuestion::class, 'parent_id')->orderBy('sort_order');
}
public function answers()
{
return $this->hasMany(QuestionnaireAnswer::class);
}
}

View File

@@ -40,6 +40,15 @@ class AttendanceService
$attendance = DB::transaction(function () use ($program, $participant, $pp, $request) {
$session = $pp->pre_registered_session ?? $program->default_staff_session ?? 'full_day';
// Kemaskini emel/telefon peserta jika diisi semasa check-in
$contactUpdate = array_filter([
'email' => $request->filled('email') ? $request->input('email') : null,
'phone' => $request->filled('phone') ? $request->input('phone') : null,
]);
if ($contactUpdate) {
$participant->update($contactUpdate);
}
$pp->update(['status' => 'checked_in']);
return Attendance::create([

View File

@@ -9,6 +9,7 @@ use App\Models\CertificateTemplate;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\Typography\FontFactory;
class CertificateService
@@ -32,21 +33,20 @@ class CertificateService
throw new \RuntimeException('Template sijil tidak dijumpai.');
}
$templatePath = Storage::path($template->image_path);
$templatePath = Storage::disk('local')->path($template->image_path);
if (! file_exists($templatePath)) {
throw new \RuntimeException('Fail template sijil tidak dijumpai di storage.');
}
$image = $this->manager->read($templatePath);
$image = $this->manager->decodePath($templatePath);
$config = $template->config_json ?? [];
$fields = $config['fields'] ?? [];
// Overlay name
if (isset($fields['name'])) {
$this->writeText($image, $certificate->participant->name, $fields['name']);
$this->writeIcBelow($image, $certificate->participant->no_kp, $fields['name']);
}
// Overlay certificate number if configured
if (isset($fields['certificate_no']) && $certificate->certificate_no) {
$this->writeText($image, $certificate->certificate_no, $fields['certificate_no']);
}
@@ -54,8 +54,9 @@ class CertificateService
$outputDir = 'certificates/' . $certificate->program_id;
$outputFile = $outputDir . '/' . $certificate->uuid . '.jpg';
Storage::makeDirectory($outputDir);
$image->toJpeg(90)->save(Storage::path($outputFile));
Storage::disk('local')->makeDirectory($outputDir);
$image->encode(new JpegEncoder(90))
->save(Storage::disk('local')->path($outputFile));
$certificate->update([
'file_path' => $outputFile,
@@ -71,40 +72,56 @@ class CertificateService
}
}
public function generatePreview(CertificateTemplate $template, string $sampleName, string $sampleNo = ''): string
public function generatePreview(CertificateTemplate $template, string $sampleName, string $sampleNo = '', ?array $overrideFields = null, string $sampleIc = '800808-08-8888'): string
{
$templatePath = Storage::path($template->image_path);
$image = $this->manager->read($templatePath);
$config = $template->config_json ?? [];
$fields = $config['fields'] ?? [];
$templatePath = Storage::disk('local')->path($template->image_path);
$image = $this->manager->decodePath($templatePath);
$fields = $overrideFields ?? ($template->config_json['fields'] ?? []);
if (isset($fields['name'])) {
$this->writeText($image, $sampleName, $fields['name']);
$this->writeIcBelow($image, $sampleIc, $fields['name']);
}
if (isset($fields['certificate_no']) && $sampleNo) {
$this->writeText($image, $sampleNo, $fields['certificate_no']);
if (isset($fields['certificate_no'])) {
$this->writeText($image, $sampleNo ?: 'ECT/2025/0001', $fields['certificate_no']);
}
return $image->toJpeg(85)->toString();
return $image->encode(new JpegEncoder(85))->toString();
}
private function writeText(\Intervention\Image\Image $image, string $text, array $cfg): void
// Tulis IC di bawah nama — auto-posisi Y, saiz font dari config atau fallback 70%
private function writeIcBelow(\Intervention\Image\Interfaces\ImageInterface $image, string $ic, array $nameCfg): void
{
$nameFontSize = (int) ($nameCfg['font_size'] ?? 48);
$icFontSize = isset($nameCfg['ic_font_size']) && (int) $nameCfg['ic_font_size'] > 0
? (int) $nameCfg['ic_font_size']
: (int) round($nameFontSize * 0.7);
$icY = (int) ($nameCfg['y'] ?? 0) + (int) round($nameFontSize * 1.5);
$this->writeText($image, $ic, array_merge($nameCfg, [
'font_size' => $icFontSize,
'y' => $icY,
'font_file' => $nameCfg['font_file'] ?? 'DejaVuSans.ttf',
]));
}
private function writeText(\Intervention\Image\Interfaces\ImageInterface $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');
$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);
$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);
$font->align($align, $valign); // v4: satu kaedah untuk horizontal + vertical
});
}

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Services;
use App\Models\Attendance;
use App\Models\Participant;
use App\Models\Program;
use App\Models\ProgramParticipant;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use League\Csv\Reader;
class ParticipantImportService
{
// Column 1 must match one of these (normalized), Column 2 must match one of these
private const VALID_COL1 = ['name', 'nama'];
private const VALID_COL2 = ['nokp', 'ic', 'nric'];
private function normalizeKey(string $key): string
{
return strtolower(preg_replace('/[^a-z0-9]/i', '', $key));
}
public function import(Program $program, UploadedFile $file, ?string $defaultSession, bool $markAttendance = false): array
{
$result = ['success' => 0, 'duplicates' => 0, 'failed' => 0, 'errors' => [], 'all_empty_ic' => false, 'invalid_headers' => false];
$csv = Reader::createFromPath($file->getRealPath(), 'r');
$csv->setHeaderOffset(0);
$csv->setOutputBOM('');
try {
$csv->addStreamFilter('convert.iconv.UTF-8/UTF-8');
} catch (\Throwable) {}
// Validate headers — first two columns are mandatory and must be in order
$rawHeaders = $csv->getHeader();
$normHeaders = array_map(fn($h) => $this->normalizeKey($h), $rawHeaders);
$col1 = $normHeaders[0] ?? '';
$col2 = $normHeaders[1] ?? '';
if (! in_array($col1, self::VALID_COL1) || ! in_array($col2, self::VALID_COL2)) {
$result['invalid_headers'] = true;
$result['found_headers'] = implode(', ', array_slice($rawHeaders, 0, 5));
return $result;
}
// Collect all rows first to detect all_empty_ic
$rows = [];
foreach ($csv->getRecords() as $rowNum => $row) {
$row = array_map('trim', $row);
$row = array_combine(
array_map(fn($k) => $this->normalizeKey($k), array_keys($row)),
array_values($row)
);
$rows[$rowNum] = $row;
}
if (empty($rows)) {
return $result;
}
// If every row has an empty no_kp, offer delete instead
$noKpValues = array_map(
fn($row) => preg_replace('/[^0-9]/', '', $row['nokp'] ?? $row['ic'] ?? ''),
$rows
);
if (count(array_filter($noKpValues)) === 0) {
$result['all_empty_ic'] = true;
return $result;
}
$session = $defaultSession ?? $program->default_staff_session;
foreach ($rows as $rowNum => $row) {
$data = [
'name' => $row['name'] ?? $row['nama'] ?? '',
'no_kp' => preg_replace('/[^0-9]/', '', $row['nokp'] ?? $row['ic'] ?? ''),
'email' => $row['email'] ?? $row['emel'] ?? null,
'phone' => $row['phone'] ?? $row['telefon'] ?? null,
'agency' => $row['agency'] ?? $row['agensi'] ?? $row['jabatan'] ?? null,
];
$validator = Validator::make($data, [
'name' => ['required', 'string', 'max:255'],
'no_kp' => ['required', 'digits:12'],
'email' => ['nullable', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
]);
if ($validator->fails()) {
$result['failed']++;
$result['errors'][] = 'Baris ' . ($rowNum + 2) . ': ' . implode(', ', $validator->errors()->all());
continue;
}
try {
DB::transaction(function () use ($program, $data, $session, $markAttendance, &$result) {
$participant = Participant::firstOrCreate(
['no_kp' => $data['no_kp']],
[
'name' => $data['name'],
'email' => $data['email'] ?: null,
'phone' => $data['phone'] ?: null,
'agency' => $data['agency'] ?: null,
'participant_type' => 'staff',
]
);
// Kemaskini emel, telefon dan agensi jika peserta sedia ada dan CSV ada data
if (! $participant->wasRecentlyCreated) {
$updates = [];
if (! empty($data['email'])) $updates['email'] = $data['email'];
if (! empty($data['phone'])) $updates['phone'] = $data['phone'];
if (! empty($data['agency'])) $updates['agency'] = $data['agency'];
if ($updates) $participant->update($updates);
}
$pp = $program->programParticipants()
->where('participant_id', $participant->id)
->first();
if ($pp) {
// Participant already registered
if ($markAttendance) {
$this->recordAttendance($program, $participant, $pp, $session);
$result['duplicates']++;
} else {
$result['duplicates']++;
}
return;
}
$newStatus = $markAttendance ? 'checked_in' : 'registered';
$pp = $program->programParticipants()->create([
'participant_id' => $participant->id,
'registration_source' => 'import',
'is_pre_registered' => true,
'pre_registered_session' => $session,
'status' => $newStatus,
'registered_at' => now(),
]);
if ($markAttendance) {
$this->recordAttendance($program, $participant, $pp, $session);
}
$result['success']++;
});
} catch (\Throwable $e) {
$result['failed']++;
$result['errors'][] = 'Baris ' . ($rowNum + 2) . ': Ralat sistem — ' . $e->getMessage();
}
}
return $result;
}
private function recordAttendance(Program $program, Participant $participant, ProgramParticipant $pp, ?string $session): void
{
$alreadyAttended = Attendance::where('program_id', $program->id)
->where('participant_id', $participant->id)
->exists();
if ($alreadyAttended) {
return;
}
$pp->update(['status' => 'checked_in']);
Attendance::create([
'program_id' => $program->id,
'participant_id' => $participant->id,
'program_participant_id' => $pp->id,
'attendance_source' => 'import',
'attendance_session' => $session ?? 'full_day',
'checked_in_at' => now(),
]);
}
}

View File

@@ -12,25 +12,21 @@ 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);
$token = Str::random(48);
$url = route('public.checkin.show', $token);
$path = 'qrcodes/' . $token . '.png';
// Ensure directory exists
Storage::makeDirectory('public/qrcodes');
Storage::disk('public')->makeDirectory('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);
Storage::disk('public')->put($path, $png);
return $program->qrCodes()->create([
'token' => $token,
@@ -41,11 +37,11 @@ class QrCodeService
public function getPublicUrl(ProgramQrCode $qrCode): string
{
return Storage::url($qrCode->qr_image_path);
return Storage::disk('public')->url($qrCode->qr_image_path);
}
public function getRawPng(ProgramQrCode $qrCode): string
{
return Storage::get($qrCode->qr_image_path);
return Storage::disk('public')->get($qrCode->qr_image_path);
}
}

View File

View File

@@ -65,7 +65,7 @@ return [
|
*/
'timezone' => 'UTC',
'timezone' => 'Asia/Kuala_Lumpur',
/*
|--------------------------------------------------------------------------

View File

@@ -36,6 +36,7 @@ return [
'serve' => true,
'throw' => false,
'report' => false,
'visibility' => 'public',
],
'public' => [

Some files were not shown because too many files have changed in this diff Show More