Compare commits
34 Commits
b0eec13d5b
...
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 |
@@ -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 Compose files (tidak perlu dalam app container)
|
||||
# Docker Compose files
|
||||
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
|
||||
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,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,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 ==="
|
||||
@@ -22,7 +22,7 @@ services:
|
||||
container_name: ecert_app
|
||||
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:
|
||||
@@ -35,22 +35,49 @@ services:
|
||||
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
|
||||
- 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:
|
||||
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:
|
||||
storage_data:
|
||||
|
||||
@@ -24,11 +24,11 @@ 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"
|
||||
@@ -45,7 +45,7 @@ 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
|
||||
@@ -61,10 +61,10 @@ 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
|
||||
extra_hosts:
|
||||
|
||||
@@ -17,7 +17,25 @@ echo "║ eCert MBIP — Container Start ║"
|
||||
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_PORT="${DB_PORT:-3306}"
|
||||
DB_DATABASE="${DB_DATABASE:-ecert_mbip}"
|
||||
@@ -44,14 +62,9 @@ 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
|
||||
@@ -72,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}")
|
||||
508
package-lock.json
generated
508
package-lock.json
generated
@@ -3,19 +3,6 @@
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
@@ -33,25 +20,6 @@
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"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": {
|
||||
"version": "0.130.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
|
||||
@@ -73,246 +41,6 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
|
||||
@@ -337,17 +65,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -522,21 +239,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": {
|
||||
"version": "2.0.5",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
|
||||
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');
|
||||
@@ -61,8 +61,14 @@ class CertificateTemplateController extends Controller
|
||||
]);
|
||||
|
||||
$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]);
|
||||
|
||||
return redirect()->route('admin.programs.template.show', $program)
|
||||
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,6 +41,13 @@ 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")
|
||||
@@ -52,7 +60,7 @@ class ParticipantController extends Controller
|
||||
'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
|
||||
@@ -104,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) {
|
||||
@@ -121,7 +181,10 @@ 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
|
||||
@@ -129,23 +192,41 @@ class ParticipantController extends Controller
|
||||
$request->validate([
|
||||
'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'],
|
||||
'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 = [
|
||||
@@ -9,6 +9,8 @@ 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
|
||||
@@ -78,9 +80,18 @@ class ProgramController extends Controller
|
||||
|
||||
$certStats = \DB::table('certificates')
|
||||
->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();
|
||||
|
||||
// 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),
|
||||
@@ -88,6 +99,11 @@ class ProgramController extends Controller
|
||||
'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'));
|
||||
@@ -122,13 +138,53 @@ class ProgramController extends Controller
|
||||
{
|
||||
$this->authorize('delete', $program);
|
||||
|
||||
if ($program->attendances()->exists()) {
|
||||
return back()->with('error', 'Program tidak boleh dipadam kerana sudah ada rekod kehadiran.');
|
||||
}
|
||||
// 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);
|
||||
|
||||
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')
|
||||
@@ -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);
|
||||
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
|
||||
@@ -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([
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
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