Compare commits

..

34 Commits

Author SHA1 Message Date
Saufi
883a8391cb fixed webhook secret 2026-05-22 21:12:38 +08:00
Saufi
278fca5ee1 fixed webhook network 2026-05-22 16:41:39 +08:00
Saufi
aa508d0924 fixed webhook 2026-05-22 16:33:51 +08:00
Saufi
66c437ce92 fixed webhook 2026-05-22 16:28:42 +08:00
Saufi
bed6c93a01 fixed webhook 2026-05-22 16:22:03 +08:00
Saufi
d8cb554eaf edit webhook master 2026-05-22 16:14:17 +08:00
Saufi
2a67d937e8 tambah webhook 2026-05-22 16:12:05 +08:00
Saufi
d9ecdfc8f6 fixed peserta download esijil error 2026-05-22 15:52:28 +08:00
Saufi
91a950a816 fungsi delete program 2026-05-22 10:29:28 +08:00
Saufi
2aae3d2d6d update emel dari file 2026-05-20 22:18:51 +08:00
Saufi
9e5ff6b85e check headr file import peserta 2026-05-20 22:07:02 +08:00
Saufi
7e4bbca2db tambah fungsi upload peserta sebagai hadir 2026-05-20 20:10:43 +08:00
Saufi
154b2c650e add npm dalam docker 2026-05-20 17:05:00 +08:00
Saufi
fa0070acec fix bilangan berjaya emel 2026-05-20 16:15:48 +08:00
Saufi
afab039f54 tambah resend email 2026-05-20 15:44:28 +08:00
Saufi
17630c65a6 fix download error 2026-05-20 12:35:15 +08:00
Saufi
7027651dd7 fix status hantar emel dan jana sijil 2026-05-20 10:20:59 +08:00
Saufi
899507070c status emel 2026-05-20 09:11:51 +08:00
Saufi
6b2769d506 tambah emel masa semak sijil 2026-05-20 08:13:36 +08:00
Saufi
7ef5092933 tambah emel untuk kakitangan 2026-05-20 07:44:08 +08:00
Saufi
b48319f77d tukar logo mbip 2026-05-19 20:56:46 +08:00
Saufi
201595912f tukar nama MB Ipoh Perak kepada MBIP 2026-05-19 20:47:08 +08:00
Saufi
2642d0cb7c tukar myCert kepada mySijil 2026-05-19 18:59:44 +08:00
Saufi
10d0ae5671 fix: toggle No. Sijil kekal off selepas simpan konfigurasi 2026-05-19 18:17:10 +08:00
Saufi
6923f7b7eb fix: tukar timezone app ke Asia/Kuala_Lumpur 2026-05-19 18:09:14 +08:00
Saufi
ac319aea1f tukar nama mbip 2026-05-19 18:04:18 +08:00
Saufi
e37044153c fix: jalankan composer install sebelum tunggu MySQL — elak stuck di wait loop 2026-05-19 17:57:41 +08:00
Saufi
32c6d1b168 fix: jalankan composer install dalam production jika vendor/ tiada 2026-05-19 16:40:08 +08:00
Saufi
5a529641dd fix: tukar env_file ke src/.env — satu .env untuk Docker dan Laravel 2026-05-19 16:31:20 +08:00
Saufi
6238941aff env docker 2026-05-19 16:24:00 +08:00
Saufi
bf53c71b45 refactor: susun semula struktur folder — Laravel source ke src/ 2026-05-19 15:58:35 +08:00
Saufi
f052251b94 setting php.ini 2026-05-19 15:45:23 +08:00
Saufi
e65fd77156 fix: buat direktori storage pada deploy pertama sebelum view:cache 2026-05-19 15:36:28 +08:00
Saufi
24bac933a8 setting php dalam docker 2026-05-19 15:07:15 +08:00
10826 changed files with 1389573 additions and 1640 deletions

View File

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

33
.gitignore vendored
View File

@@ -1,32 +1,5 @@
*.log
.DS_Store
.env .env
.env.backup !src/.env.example
.env.production
.phpactor.json
.phpunit.result.cache .phpunit.result.cache
/.codex node_modules
/.cursor/ docker/webhook/hooks.json
/.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/

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Attendance;
use App\Models\Certificate;
use App\Models\EmailLog;
use App\Models\Participant;
use App\Models\Program;
use App\Models\QuestionnaireResponse;
class DashboardController extends Controller
{
public function index()
{
$stats = [
'total_programs' => Program::count(),
'active_programs' => Program::where('status', 'published')->count(),
'total_participants' => Participant::count(),
'total_attendances' => Attendance::count(),
'total_certificates' => Certificate::count(),
'generated_certs' => Certificate::whereIn('status', ['generated', 'emailed', 'downloaded'])->count(),
'downloaded_certs' => Certificate::where('status', 'downloaded')->count(),
'total_responses' => QuestionnaireResponse::count(),
'pending_emails' => EmailLog::where('status', 'pending')->count(),
];
$recentPrograms = Program::with('creator')
->latest()
->limit(5)
->get();
return view('admin.dashboard', compact('stats', 'recentPrograms'));
}
}

View File

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

View File

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

27
deploy.sh Normal file
View File

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

View File

@@ -22,7 +22,7 @@ services:
container_name: ecert_app container_name: ecert_app
restart: always restart: always
volumes: volumes:
- .:/var/www - ./src:/var/www
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro - ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
- storage_data:/var/www/storage - storage_data:/var/www/storage
environment: environment:
@@ -35,22 +35,49 @@ services:
container_name: ecert_nginx container_name: ecert_nginx
restart: always restart: always
volumes: volumes:
- .:/var/www:ro - ./src:/var/www:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- storage_data:/var/www/storage:ro - storage_data:/var/www/storage:ro
# ── Node.js Asset Builder (one-time, run manually) ────────────────────────
node-build:
image: node:lts-alpine
container_name: ecert_node_build
working_dir: /app
volumes:
- ./src:/app
command: sh -c "npm ci && npm run build"
profiles:
- build
# ── Queue Worker (production) ────────────────────────────────────────────── # ── Queue Worker (production) ──────────────────────────────────────────────
queue: queue:
container_name: ecert_queue container_name: ecert_queue
restart: always restart: always
volumes: volumes:
- .:/var/www - ./src:/var/www
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro - ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
- storage_data:/var/www/storage - storage_data:/var/www/storage
environment: environment:
APP_ENV: production APP_ENV: production
extra_hosts: [] 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: volumes:
storage_data: storage_data:

View File

@@ -24,11 +24,11 @@ services:
restart: unless-stopped restart: unless-stopped
working_dir: /var/www working_dir: /var/www
volumes: volumes:
- .:/var/www - ./src:/var/www
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro - ./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 - ./docker/php/php-dev.ini:/usr/local/etc/php/conf.d/99-ecert-dev.ini:ro
env_file: env_file:
- .env - src/.env
environment: environment:
APP_ENV: local APP_ENV: local
APP_DEBUG: "true" APP_DEBUG: "true"
@@ -45,7 +45,7 @@ services:
ports: ports:
- "8003:80" - "8003:80"
volumes: volumes:
- .:/var/www:ro - ./src:/var/www:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: depends_on:
- app - app
@@ -61,10 +61,10 @@ services:
restart: unless-stopped restart: unless-stopped
working_dir: /var/www working_dir: /var/www
volumes: volumes:
- .:/var/www - ./src:/var/www
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro - ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
env_file: env_file:
- .env - src/.env
environment: environment:
APP_ENV: local APP_ENV: local
extra_hosts: extra_hosts:

View File

@@ -17,7 +17,25 @@ echo "║ eCert MBIP — Container Start ║"
echo "╚══════════════════════════════════════╝" echo "╚══════════════════════════════════════╝"
echo "" echo ""
# ── 1. Tunggu MySQL bersedia ────────────────────────────────────────────────── # ── 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_HOST="${DB_HOST:-host.docker.internal}"
DB_PORT="${DB_PORT:-3306}" DB_PORT="${DB_PORT:-3306}"
DB_DATABASE="${DB_DATABASE:-ecert_mbip}" DB_DATABASE="${DB_DATABASE:-ecert_mbip}"
@@ -44,14 +62,9 @@ done
echo "" echo ""
echo "✓ MySQL bersedia." echo "✓ MySQL bersedia."
# ── 2. Pasang Composer dependencies (development sahaja) ───────────────────── # ── 2b. Fix storage permissions (penting untuk named volume di production) ────
if [ "${APP_ENV}" != "production" ] && [ ! -d /var/www/vendor ]; then chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache 2>/dev/null || true
echo "📦 Memasang Composer dependencies (dev)..." chmod -R 775 /var/www/storage /var/www/bootstrap/cache 2>/dev/null || true
composer install \
--no-interaction \
--no-progress \
--prefer-dist
fi
# ── 3. Generate APP_KEY jika kosong ─────────────────────────────────────────── # ── 3. Generate APP_KEY jika kosong ───────────────────────────────────────────
if [ -z "${APP_KEY}" ] || [ "${APP_KEY}" = "" ]; then if [ -z "${APP_KEY}" ] || [ "${APP_KEY}" = "" ]; then
@@ -72,13 +85,20 @@ php artisan storage:link 2>/dev/null || true
# ── 6. Cache (production sahaja) ────────────────────────────────────────────── # ── 6. Cache (production sahaja) ──────────────────────────────────────────────
if [ "${APP_ENV}" = "production" ]; then 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..." echo "⚡ Caching config, routes, views..."
php artisan config:cache php artisan config:cache
php artisan route:cache php artisan route:cache
php artisan view:cache php artisan view:cache
php artisan event:cache php artisan event:cache
# Opcache: matikan validate_timestamps untuk prestasi
# (sudah dikonfigur dalam php.ini prod)
fi fi
echo "" echo ""

View File

@@ -62,6 +62,13 @@ server {
try_files $uri =404; 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 ───────────────────────────────────────── # ── Halang akses fail tersembunyi ─────────────────────────────────────────
location ~ /\. { location ~ /\. {
deny all; deny all;

View File

@@ -4,7 +4,7 @@
############################################################################### ###############################################################################
FROM php:8.4-fpm 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" org.opencontainers.image.description="Sistem Pengurusan Sijil Digital MBIP"
# ── System libraries ────────────────────────────────────────────────────────── # ── System libraries ──────────────────────────────────────────────────────────

View File

@@ -39,6 +39,13 @@ display_startup_errors = Off
log_errors = On log_errors = On
error_log = /var/log/php_errors.log 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] [imagick]
imagick.skip_version_check = 1 imagick.skip_version_check = 1

View File

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

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

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

Binary file not shown.

722
manual/generate_manual.py Normal file
View File

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

508
package-lock.json generated
View File

@@ -3,19 +3,6 @@
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": {
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.3",
"chart.js": "^4.4.0",
"jquery": "^3.7.1"
},
"devDependencies": {
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1",
"vite": "^8.0.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -33,25 +20,6 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.130.0", "version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
@@ -73,246 +41,6 @@
"url": "https://opencollective.com/popperjs" "url": "https://opencollective.com/popperjs"
} }
}, },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
@@ -337,17 +65,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -522,21 +239,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -630,216 +332,6 @@
"lightningcss-win32-x64-msvc": "1.32.0" "lightningcss-win32-x64-msvc": "1.32.0"
} }
}, },
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": { "node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",

31
src/.gitignore vendored Normal file
View File

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

View File

View File

@@ -8,6 +8,8 @@ use App\Models\Certificate;
use App\Models\Program; use App\Models\Program;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View; use Illuminate\View\View;
class CertificateController extends Controller class CertificateController extends Controller
@@ -97,6 +99,31 @@ class CertificateController extends Controller
return back()->with('success', "Penghantaran emel sijil dijadualkan untuk {$toEmail} peserta."); 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 private function buildCertNo(Program $program, int $seq): string
{ {
$year = now()->format('Y'); $year = now()->format('Y');

View File

@@ -61,8 +61,14 @@ class CertificateTemplateController extends Controller
]); ]);
$config = $template->config_json ?? []; $config = $template->config_json ?? [];
$config['fields'] = array_merge($config['fields'] ?? [], $request->fields); $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]); $template->update(['config_json' => $config]);
return redirect()->route('admin.programs.template.show', $program) return redirect()->route('admin.programs.template.show', $program)

View File

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

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Certificate;
use App\Models\Participant; use App\Models\Participant;
use App\Models\Program; use App\Models\Program;
use App\Models\ProgramParticipant; use App\Models\ProgramParticipant;
@@ -40,6 +41,13 @@ class ParticipantController extends Controller
$programParticipants = $query->paginate(20)->withQueryString(); $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') $countRow = DB::table('program_participants')
->where('program_id', $program->id) ->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") ->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")
@@ -52,7 +60,7 @@ class ParticipantController extends Controller
'checked_in' => (int) ($countRow->checked_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 public function create(Program $program): View
@@ -104,6 +112,58 @@ class ParticipantController extends Controller
return back()->with('success', 'Peserta berjaya ditambah.'); 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 public function destroy(Program $program, ProgramParticipant $pp): RedirectResponse
{ {
if ($pp->program_id !== $program->id) { if ($pp->program_id !== $program->id) {
@@ -121,7 +181,10 @@ class ParticipantController extends Controller
public function importForm(Program $program): View 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 public function import(Program $program, Request $request, ParticipantImportService $importer): RedirectResponse
@@ -129,23 +192,41 @@ class ParticipantController extends Controller
$request->validate([ $request->validate([
'csv_file' => ['required', 'file', 'mimes:csv,txt', 'max:5120'], 'csv_file' => ['required', 'file', 'mimes:csv,txt', 'max:5120'],
'session' => ['nullable', 'in:pagi,petang,full_day'], '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( $result = $importer->import(
$program, $program,
$request->file('csv_file'), $request->file('csv_file'),
$request->input('session', $program->default_staff_session) $request->input('session', $program->default_staff_session),
$markAttendance
); );
AuditLogService::log('participant.imported', $program, [], [ AuditLogService::log('participant.imported', $program, [], [
'success' => $result['success'], 'success' => $result['success'],
'duplicates' => $result['duplicates'], 'duplicates' => $result['duplicates'],
'failed' => $result['failed'], 'failed' => $result['failed'],
'mark_attendance'=> $markAttendance,
]); ]);
return back()->with('import_result', $result); 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 public function export(Program $program): \Symfony\Component\HttpFoundation\StreamedResponse
{ {
$headers = [ $headers = [

View File

@@ -9,6 +9,8 @@ use App\Models\Program;
use App\Services\AuditLogService; use App\Services\AuditLogService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View; use Illuminate\View\View;
class ProgramController extends Controller class ProgramController extends Controller
@@ -78,9 +80,18 @@ class ProgramController extends Controller
$certStats = \DB::table('certificates') $certStats = \DB::table('certificates')
->where('program_id', $program->id) ->where('program_id', $program->id)
->selectRaw("COUNT(*) as total, SUM(status IN ('generated','emailed','downloaded')) as cert_generated") ->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(); ->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 = [ $stats = [
'total_participants' => (int) ($ppStats->total ?? 0), 'total_participants' => (int) ($ppStats->total ?? 0),
'pre_registered' => (int) ($ppStats->pre_registered ?? 0), 'pre_registered' => (int) ($ppStats->pre_registered ?? 0),
@@ -88,6 +99,11 @@ class ProgramController extends Controller
'total_attendances' => $program->attendances()->count(), 'total_attendances' => $program->attendances()->count(),
'total_certificates' => (int) ($certStats->total ?? 0), 'total_certificates' => (int) ($certStats->total ?? 0),
'generated_certificates' => (int) ($certStats->cert_generated ?? 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')); return view('admin.programs.show', compact('program', 'stats'));
@@ -122,13 +138,53 @@ class ProgramController extends Controller
{ {
$this->authorize('delete', $program); $this->authorize('delete', $program);
if ($program->attendances()->exists()) { // Capture audit data before deletion
return back()->with('error', 'Program tidak boleh dipadam kerana sudah ada rekod kehadiran.'); $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; $title = $program->title;
AuditLogService::log('program.deleted', $program);
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(); $program->delete();
});
// Delete physical files after transaction
foreach (array_merge($certFiles, $templateFiles, $qrFiles) as $path) {
if ($path) {
Storage::disk('local')->delete($path);
}
}
return redirect() return redirect()
->route('admin.programs.index') ->route('admin.programs.index')

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,15 @@ class AttendanceService
$attendance = DB::transaction(function () use ($program, $participant, $pp, $request) { $attendance = DB::transaction(function () use ($program, $participant, $pp, $request) {
$session = $pp->pre_registered_session ?? $program->default_staff_session ?? 'full_day'; $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']); $pp->update(['status' => 'checked_in']);
return Attendance::create([ return Attendance::create([

View File

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

View File

View File

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

View File

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

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