Compare commits
44 Commits
576c71c960
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
883a8391cb | ||
|
|
278fca5ee1 | ||
|
|
aa508d0924 | ||
|
|
66c437ce92 | ||
|
|
bed6c93a01 | ||
|
|
d8cb554eaf | ||
|
|
2a67d937e8 | ||
|
|
d9ecdfc8f6 | ||
|
|
91a950a816 | ||
|
|
2aae3d2d6d | ||
|
|
9e5ff6b85e | ||
|
|
7e4bbca2db | ||
|
|
154b2c650e | ||
|
|
fa0070acec | ||
|
|
afab039f54 | ||
|
|
17630c65a6 | ||
|
|
7027651dd7 | ||
|
|
899507070c | ||
|
|
6b2769d506 | ||
|
|
7ef5092933 | ||
|
|
b48319f77d | ||
|
|
201595912f | ||
|
|
2642d0cb7c | ||
|
|
10d0ae5671 | ||
|
|
6923f7b7eb | ||
|
|
ac319aea1f | ||
|
|
e37044153c | ||
|
|
32c6d1b168 | ||
|
|
5a529641dd | ||
|
|
6238941aff | ||
|
|
bf53c71b45 | ||
|
|
f052251b94 | ||
|
|
e65fd77156 | ||
|
|
24bac933a8 | ||
|
|
b0eec13d5b | ||
|
|
f39eca4b1c | ||
|
|
12aea2cbff | ||
|
|
d597bf45fb | ||
|
|
0417a6698a | ||
|
|
29d85eea86 | ||
|
|
0fd202f974 | ||
|
|
756b73e3ee | ||
|
|
55c077ee48 | ||
|
|
69c91dfb4b |
@@ -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
33
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
27
deploy.sh
Normal 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 ==="
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
8
docker/webhook/Dockerfile
Normal file
8
docker/webhook/Dockerfile
Normal 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
18
docker/webhook/hooks.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
BIN
manual/Manual_Pengguna_eCert_MBIP.docx
Normal file
BIN
manual/Manual_Pengguna_eCert_MBIP.docx
Normal file
Binary file not shown.
722
manual/generate_manual.py
Normal file
722
manual/generate_manual.py
Normal 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
2046
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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 (1–5)</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
|
||||
@@ -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
|
||||
0
.gitattributes → src/.gitattributes
vendored
0
.gitattributes → src/.gitattributes
vendored
31
src/.gitignore
vendored
Normal file
31
src/.gitignore
vendored
Normal 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/
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
47
src/app/Http/Controllers/Admin/DashboardController.php
Normal file
47
src/app/Http/Controllers/Admin/DashboardController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
60
src/app/Http/Controllers/Admin/ProfileController.php
Normal file
60
src/app/Http/Controllers/Admin/ProfileController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
221
src/app/Http/Controllers/Admin/ProgramController.php
Normal file
221
src/app/Http/Controllers/Admin/ProgramController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
155
src/app/Http/Controllers/Admin/QuestionController.php
Normal file
155
src/app/Http/Controllers/Admin/QuestionController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
135
src/app/Jobs/SendCertificateEmailJob.php
Normal file
135
src/app/Jobs/SendCertificateEmailJob.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
43
src/app/Models/QuestionnaireQuestion.php
Normal file
43
src/app/Models/QuestionnaireQuestion.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
182
src/app/Services/ParticipantImportService.php
Normal file
182
src/app/Services/ParticipantImportService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
0
composer.lock → src/composer.lock
generated
0
composer.lock → src/composer.lock
generated
@@ -65,7 +65,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
'timezone' => 'Asia/Kuala_Lumpur',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -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
Reference in New Issue
Block a user