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
|
.gitattributes
|
||||||
|
|
||||||
# Dependencies (akan dipasang semula dalam container)
|
# Dependencies (akan dipasang semula dalam container)
|
||||||
node_modules
|
src/node_modules
|
||||||
vendor
|
src/vendor
|
||||||
|
|
||||||
# Environment secrets
|
# Environment secrets
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.docker
|
!src/.env.example
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Build output (akan dihasilkan semula)
|
# Build output
|
||||||
public/hot
|
src/public/hot
|
||||||
public/build
|
src/public/build
|
||||||
|
|
||||||
# Dev tools
|
# Dev tools
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
.editorconfig
|
src/.editorconfig
|
||||||
.phpunit.cache
|
src/.phpunit.cache
|
||||||
phpunit.xml
|
src/phpunit.xml
|
||||||
|
|
||||||
# Docker Compose files (tidak perlu dalam app container)
|
# Docker Compose files
|
||||||
docker-compose*.yml
|
docker-compose*.yml
|
||||||
# docker/ TIDAK diexclude — Dockerfile perlukan docker/entrypoint.sh dan docker/php/php.ini
|
# docker/ TIDAK diexclude — Dockerfile perlukan docker/entrypoint.sh
|
||||||
|
|
||||||
# Logs & cache
|
# Logs & cache
|
||||||
storage/logs/*
|
src/storage/logs/*
|
||||||
storage/framework/cache/*
|
src/storage/framework/cache/*
|
||||||
storage/framework/sessions/*
|
src/storage/framework/sessions/*
|
||||||
storage/framework/views/*
|
src/storage/framework/views/*
|
||||||
bootstrap/cache/*
|
src/bootstrap/cache/*
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
tests/
|
src/tests/
|
||||||
|
|||||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -1,32 +1,5 @@
|
|||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
.env
|
.env
|
||||||
.env.backup
|
!src/.env.example
|
||||||
.env.production
|
|
||||||
.phpactor.json
|
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
/.codex
|
node_modules
|
||||||
/.cursor/
|
docker/webhook/hooks.json
|
||||||
/.idea
|
|
||||||
/.nova
|
|
||||||
/.phpunit.cache
|
|
||||||
/.vscode
|
|
||||||
/.zed
|
|
||||||
/auth.json
|
|
||||||
/node_modules
|
|
||||||
/public/build
|
|
||||||
/public/fonts-manifest.dev.json
|
|
||||||
/public/hot
|
|
||||||
/public/storage
|
|
||||||
/storage/*.key
|
|
||||||
/storage/pail
|
|
||||||
/vendor
|
|
||||||
_ide_helper.php
|
|
||||||
Homestead.json
|
|
||||||
Homestead.yaml
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Application-specific storage (private files)
|
|
||||||
/storage/app/private/certificates/
|
|
||||||
/storage/app/private/imports/
|
|
||||||
/storage/app/public/qrcodes/
|
|
||||||
|
|||||||
@@ -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
|
container_name: ecert_app
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www
|
- ./src:/var/www
|
||||||
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
|
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
|
||||||
- storage_data:/var/www/storage
|
- storage_data:/var/www/storage
|
||||||
environment:
|
environment:
|
||||||
@@ -35,22 +35,49 @@ services:
|
|||||||
container_name: ecert_nginx
|
container_name: ecert_nginx
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www:ro
|
- ./src:/var/www:ro
|
||||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- storage_data:/var/www/storage:ro
|
- storage_data:/var/www/storage:ro
|
||||||
|
|
||||||
|
# ── Node.js Asset Builder (one-time, run manually) ────────────────────────
|
||||||
|
node-build:
|
||||||
|
image: node:lts-alpine
|
||||||
|
container_name: ecert_node_build
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./src:/app
|
||||||
|
command: sh -c "npm ci && npm run build"
|
||||||
|
profiles:
|
||||||
|
- build
|
||||||
|
|
||||||
# ── Queue Worker (production) ──────────────────────────────────────────────
|
# ── Queue Worker (production) ──────────────────────────────────────────────
|
||||||
queue:
|
queue:
|
||||||
container_name: ecert_queue
|
container_name: ecert_queue
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www
|
- ./src:/var/www
|
||||||
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
|
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
|
||||||
- storage_data:/var/www/storage
|
- storage_data:/var/www/storage
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: production
|
APP_ENV: production
|
||||||
extra_hosts: []
|
extra_hosts: []
|
||||||
|
|
||||||
|
# ── Webhook Deploy (GitHub → auto pull + migrate) ──────────────────────────
|
||||||
|
webhook:
|
||||||
|
build:
|
||||||
|
context: ./docker/webhook
|
||||||
|
container_name: ecert_webhook
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /root/.ssh:/root/.ssh:ro
|
||||||
|
- ./docker/webhook/hooks.json:/etc/webhook/hooks.json:ro
|
||||||
|
- ./deploy.sh:/deploy.sh:ro
|
||||||
|
- .:/srv/ecert
|
||||||
|
command: -hooks=/etc/webhook/hooks.json -verbose
|
||||||
|
networks:
|
||||||
|
- ecert
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
volumes:
|
volumes:
|
||||||
storage_data:
|
storage_data:
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /var/www
|
working_dir: /var/www
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www
|
- ./src:/var/www
|
||||||
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
|
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
|
||||||
- ./docker/php/php-dev.ini:/usr/local/etc/php/conf.d/99-ecert-dev.ini:ro
|
- ./docker/php/php-dev.ini:/usr/local/etc/php/conf.d/99-ecert-dev.ini:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- src/.env
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: local
|
APP_ENV: local
|
||||||
APP_DEBUG: "true"
|
APP_DEBUG: "true"
|
||||||
@@ -45,7 +45,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8003:80"
|
- "8003:80"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www:ro
|
- ./src:/var/www:ro
|
||||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
@@ -61,10 +61,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /var/www
|
working_dir: /var/www
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www
|
- ./src:/var/www
|
||||||
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
|
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-ecert.ini:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- src/.env
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: local
|
APP_ENV: local
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@@ -17,7 +17,25 @@ echo "║ eCert MBIP — Container Start ║"
|
|||||||
echo "╚══════════════════════════════════════╝"
|
echo "╚══════════════════════════════════════╝"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ── 1. Tunggu MySQL bersedia ──────────────────────────────────────────────────
|
# ── 1. Pasang Composer dependencies (sebelum tunggu MySQL) ───────────────────
|
||||||
|
if [ ! -d /var/www/vendor ]; then
|
||||||
|
echo "📦 Memasang Composer dependencies..."
|
||||||
|
if [ "${APP_ENV}" = "production" ]; then
|
||||||
|
composer install \
|
||||||
|
--no-interaction \
|
||||||
|
--no-dev \
|
||||||
|
--no-progress \
|
||||||
|
--prefer-dist \
|
||||||
|
--optimize-autoloader
|
||||||
|
else
|
||||||
|
composer install \
|
||||||
|
--no-interaction \
|
||||||
|
--no-progress \
|
||||||
|
--prefer-dist
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Tunggu MySQL bersedia ──────────────────────────────────────────────────
|
||||||
DB_HOST="${DB_HOST:-host.docker.internal}"
|
DB_HOST="${DB_HOST:-host.docker.internal}"
|
||||||
DB_PORT="${DB_PORT:-3306}"
|
DB_PORT="${DB_PORT:-3306}"
|
||||||
DB_DATABASE="${DB_DATABASE:-ecert_mbip}"
|
DB_DATABASE="${DB_DATABASE:-ecert_mbip}"
|
||||||
@@ -44,14 +62,9 @@ done
|
|||||||
echo ""
|
echo ""
|
||||||
echo "✓ MySQL bersedia."
|
echo "✓ MySQL bersedia."
|
||||||
|
|
||||||
# ── 2. Pasang Composer dependencies (development sahaja) ─────────────────────
|
# ── 2b. Fix storage permissions (penting untuk named volume di production) ────
|
||||||
if [ "${APP_ENV}" != "production" ] && [ ! -d /var/www/vendor ]; then
|
chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache 2>/dev/null || true
|
||||||
echo "📦 Memasang Composer dependencies (dev)..."
|
chmod -R 775 /var/www/storage /var/www/bootstrap/cache 2>/dev/null || true
|
||||||
composer install \
|
|
||||||
--no-interaction \
|
|
||||||
--no-progress \
|
|
||||||
--prefer-dist
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 3. Generate APP_KEY jika kosong ───────────────────────────────────────────
|
# ── 3. Generate APP_KEY jika kosong ───────────────────────────────────────────
|
||||||
if [ -z "${APP_KEY}" ] || [ "${APP_KEY}" = "" ]; then
|
if [ -z "${APP_KEY}" ] || [ "${APP_KEY}" = "" ]; then
|
||||||
@@ -72,13 +85,20 @@ php artisan storage:link 2>/dev/null || true
|
|||||||
|
|
||||||
# ── 6. Cache (production sahaja) ──────────────────────────────────────────────
|
# ── 6. Cache (production sahaja) ──────────────────────────────────────────────
|
||||||
if [ "${APP_ENV}" = "production" ]; then
|
if [ "${APP_ENV}" = "production" ]; then
|
||||||
|
# Pastikan direktori storage wujud (penting bila named volume kosong pada deploy pertama)
|
||||||
|
mkdir -p /var/www/storage/framework/views \
|
||||||
|
/var/www/storage/framework/cache/data \
|
||||||
|
/var/www/storage/framework/sessions \
|
||||||
|
/var/www/storage/logs \
|
||||||
|
/var/www/storage/app/public
|
||||||
|
chown -R www-data:www-data /var/www/storage
|
||||||
|
chmod -R 775 /var/www/storage
|
||||||
|
|
||||||
echo "⚡ Caching config, routes, views..."
|
echo "⚡ Caching config, routes, views..."
|
||||||
php artisan config:cache
|
php artisan config:cache
|
||||||
php artisan route:cache
|
php artisan route:cache
|
||||||
php artisan view:cache
|
php artisan view:cache
|
||||||
php artisan event:cache
|
php artisan event:cache
|
||||||
# Opcache: matikan validate_timestamps untuk prestasi
|
|
||||||
# (sudah dikonfigur dalam php.ini prod)
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── GitHub Webhook Deploy ─────────────────────────────────────────────────
|
||||||
|
location /hooks/ {
|
||||||
|
proxy_pass http://ecert_webhook:9000/hooks/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
# ── Halang akses fail tersembunyi ─────────────────────────────────────────
|
# ── Halang akses fail tersembunyi ─────────────────────────────────────────
|
||||||
location ~ /\. {
|
location ~ /\. {
|
||||||
deny all;
|
deny all;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
FROM php:8.4-fpm
|
FROM php:8.4-fpm
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="eCert MBIP" \
|
LABEL org.opencontainers.image.title="mySijil MBIP" \
|
||||||
org.opencontainers.image.description="Sistem Pengurusan Sijil Digital MBIP"
|
org.opencontainers.image.description="Sistem Pengurusan Sijil Digital MBIP"
|
||||||
|
|
||||||
# ── System libraries ──────────────────────────────────────────────────────────
|
# ── System libraries ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ display_startup_errors = Off
|
|||||||
log_errors = On
|
log_errors = On
|
||||||
error_log = /var/log/php_errors.log
|
error_log = /var/log/php_errors.log
|
||||||
|
|
||||||
|
; ── Output buffering (elak "headers already sent" dari PHP notices) ───────────
|
||||||
|
output_buffering = 4096
|
||||||
|
|
||||||
|
; ── Temporary files ───────────────────────────────────────────────────────────
|
||||||
|
sys_temp_dir = /tmp
|
||||||
|
upload_tmp_dir = /tmp
|
||||||
|
|
||||||
; ── imagick ───────────────────────────────────────────────────────────────────
|
; ── imagick ───────────────────────────────────────────────────────────────────
|
||||||
[imagick]
|
[imagick]
|
||||||
imagick.skip_version_check = 1
|
imagick.skip_version_check = 1
|
||||||
|
|||||||
8
docker/webhook/Dockerfile
Normal file
8
docker/webhook/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
RUN go install github.com/adnanh/webhook@2.8.1
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
RUN apk add --no-cache git docker-cli
|
||||||
|
COPY --from=builder /go/bin/webhook /usr/local/bin/webhook
|
||||||
|
EXPOSE 9000
|
||||||
|
ENTRYPOINT ["/usr/local/bin/webhook"]
|
||||||
18
docker/webhook/hooks.json
Normal file
18
docker/webhook/hooks.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "deploy",
|
||||||
|
"execute-command": "/deploy.sh",
|
||||||
|
"command-working-directory": "/srv/ecert",
|
||||||
|
"response-message": "Deploy dimulakan.",
|
||||||
|
"trigger-rule": {
|
||||||
|
"match": {
|
||||||
|
"type": "payload-hmac-sha256",
|
||||||
|
"secret": "{{ .Env.WEBHOOK_SECRET }}",
|
||||||
|
"parameter": {
|
||||||
|
"source": "header",
|
||||||
|
"name": "X-Hub-Signature-256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
manual/Manual_Pengguna_eCert_MBIP.docx
Normal file
BIN
manual/Manual_Pengguna_eCert_MBIP.docx
Normal file
Binary file not shown.
722
manual/generate_manual.py
Normal file
722
manual/generate_manual.py
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
"""
|
||||||
|
Penjana Manual Pengguna eCert MBIP
|
||||||
|
Format: Microsoft Word (.docx)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt, Cm, RGBColor, Inches
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
BASE_URL = "https://mysijil.mbip.my"
|
||||||
|
|
||||||
|
doc = Document()
|
||||||
|
|
||||||
|
# ── Margin halaman ────────────────────────────────────────────────────────────
|
||||||
|
for section in doc.sections:
|
||||||
|
section.top_margin = Cm(2.5)
|
||||||
|
section.bottom_margin = Cm(2.5)
|
||||||
|
section.left_margin = Cm(3.0)
|
||||||
|
section.right_margin = Cm(2.5)
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
def add_heading(text, level=1):
|
||||||
|
h = doc.add_heading(text, level=level)
|
||||||
|
h.runs[0].font.color.rgb = RGBColor(0x1a, 0x3a, 0x6b)
|
||||||
|
return h
|
||||||
|
|
||||||
|
def add_body(text):
|
||||||
|
p = doc.add_paragraph(text)
|
||||||
|
p.runs[0].font.size = Pt(11)
|
||||||
|
return p
|
||||||
|
|
||||||
|
def add_bullet(text):
|
||||||
|
p = doc.add_paragraph(text, style='List Bullet')
|
||||||
|
p.runs[0].font.size = Pt(11)
|
||||||
|
return p
|
||||||
|
|
||||||
|
def add_screenshot_box(caption, url, height_cm=7):
|
||||||
|
"""Kotak placeholder untuk tangkapan skrin."""
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
# Jadual satu sel sebagai kotak
|
||||||
|
tbl = doc.add_table(rows=1, cols=1)
|
||||||
|
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||||
|
cell = tbl.cell(0, 0)
|
||||||
|
|
||||||
|
# Warna latar
|
||||||
|
tc = cell._tc
|
||||||
|
tcPr = tc.get_or_add_tcPr()
|
||||||
|
shd = OxmlElement('w:shd')
|
||||||
|
shd.set(qn('w:val'), 'clear')
|
||||||
|
shd.set(qn('w:color'), 'auto')
|
||||||
|
shd.set(qn('w:fill'), 'EEF2FF')
|
||||||
|
tcPr.append(shd)
|
||||||
|
|
||||||
|
# Tinggi sel
|
||||||
|
trPr = tbl.rows[0]._tr.get_or_add_trPr()
|
||||||
|
trHeight = OxmlElement('w:trHeight')
|
||||||
|
trHeight.set(qn('w:val'), str(int(height_cm * 567)))
|
||||||
|
trHeight.set(qn('w:hRule'), 'exact')
|
||||||
|
trPr.append(trHeight)
|
||||||
|
|
||||||
|
# Teks dalam kotak
|
||||||
|
p = cell.paragraphs[0]
|
||||||
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run = p.add_run(f"\n\n[ TANGKAPAN SKRIN ]\n\n{caption}")
|
||||||
|
run.font.size = Pt(10)
|
||||||
|
run.font.color.rgb = RGBColor(0x64, 0x74, 0x8B)
|
||||||
|
run.font.italic = True
|
||||||
|
|
||||||
|
# Label URL
|
||||||
|
p2 = doc.add_paragraph()
|
||||||
|
p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
run2 = p2.add_run(f"URL: {url}")
|
||||||
|
run2.font.size = Pt(9)
|
||||||
|
run2.font.color.rgb = RGBColor(0x1a, 0x56, 0xa0)
|
||||||
|
run2.font.italic = True
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
def add_note(text):
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.add_run("Nota: ").bold = True
|
||||||
|
run = p.add_run(text)
|
||||||
|
run.font.size = Pt(10)
|
||||||
|
run.font.italic = True
|
||||||
|
p.paragraph_format.left_indent = Cm(0.5)
|
||||||
|
|
||||||
|
def page_break():
|
||||||
|
doc.add_page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# HALAMAN TAJUK
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
doc.add_paragraph()
|
||||||
|
doc.add_paragraph()
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
t = doc.add_paragraph("eCert MBIP")
|
||||||
|
t.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
r = t.runs[0]
|
||||||
|
r.font.size = Pt(32)
|
||||||
|
r.font.bold = True
|
||||||
|
r.font.color.rgb = RGBColor(0x1a, 0x3a, 0x6b)
|
||||||
|
|
||||||
|
t2 = doc.add_paragraph("Sistem Pengurusan Sijil Digital")
|
||||||
|
t2.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
r2 = t2.runs[0]
|
||||||
|
r2.font.size = Pt(18)
|
||||||
|
r2.font.color.rgb = RGBColor(0x1a, 0x3a, 0x6b)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
t3 = doc.add_paragraph("MANUAL PENGGUNA — PENTADBIR")
|
||||||
|
t3.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
r3 = t3.runs[0]
|
||||||
|
r3.font.size = Pt(16)
|
||||||
|
r3.font.bold = True
|
||||||
|
r3.font.color.rgb = RGBColor(0x55, 0x55, 0x55)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
t4 = doc.add_paragraph("Majlis Bandaraya Ipoh Perak (MBIP)")
|
||||||
|
t4.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
t4.runs[0].font.size = Pt(12)
|
||||||
|
|
||||||
|
t5 = doc.add_paragraph(f"Versi 1.0 · {datetime.date.today().strftime('%B %Y')}")
|
||||||
|
t5.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||||
|
t5.runs[0].font.size = Pt(11)
|
||||||
|
t5.runs[0].font.color.rgb = RGBColor(0x88, 0x88, 0x88)
|
||||||
|
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# ISI KANDUNGAN (placeholder manual)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("Isi Kandungan", level=1)
|
||||||
|
|
||||||
|
toc_items = [
|
||||||
|
("1", "Pengenalan"),
|
||||||
|
("2", "Log Masuk ke Sistem"),
|
||||||
|
("3", "Dashboard Utama"),
|
||||||
|
("4", "Pengurusan Program"),
|
||||||
|
(" 4.1", "Cipta Program Baru"),
|
||||||
|
(" 4.2", "Kemaskini Maklumat Program"),
|
||||||
|
(" 4.3", "Tetapan Check-in dan Muat Turun"),
|
||||||
|
(" 4.4", "Publish dan Tutup Program"),
|
||||||
|
("5", "Pengurusan Peserta"),
|
||||||
|
(" 5.1", "Lihat Senarai Peserta"),
|
||||||
|
(" 5.2", "Tambah Peserta Satu-Satu"),
|
||||||
|
(" 5.3", "Import Peserta dari Excel"),
|
||||||
|
(" 5.4", "Export Senarai Peserta"),
|
||||||
|
("6", "Kod QR Check-in"),
|
||||||
|
("7", "Template Sijil"),
|
||||||
|
(" 7.1", "Muat Naik Template"),
|
||||||
|
(" 7.2", "Konfigurasi Kedudukan Teks"),
|
||||||
|
(" 7.3", "Jana Pratonton Sijil"),
|
||||||
|
("8", "Soalselidik Program"),
|
||||||
|
("9", "Pengurusan Sijil"),
|
||||||
|
("10", "Statistik Program"),
|
||||||
|
("11", "Set Soalselidik"),
|
||||||
|
(" 11.1", "Cipta Set Soalselidik"),
|
||||||
|
(" 11.2", "Tambah dan Urus Soalan"),
|
||||||
|
("12", "Pengurusan Pengguna (Super Admin)"),
|
||||||
|
("13", "Profil Pengguna"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for num, title in toc_items:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.left_indent = Cm(0.5) if num.startswith(" ") else Cm(0)
|
||||||
|
r = p.add_run(f"{num.strip()} {title}")
|
||||||
|
r.font.size = Pt(11)
|
||||||
|
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 1: PENGENALAN
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("1. Pengenalan")
|
||||||
|
add_body(
|
||||||
|
"eCert MBIP ialah sistem pengurusan sijil digital yang dibangunkan untuk Majlis Bandaraya Ipoh Perak (MBIP). "
|
||||||
|
"Sistem ini membolehkan pentadbir mengurus program, menguruskan peserta, menjana sijil digital secara automatik, "
|
||||||
|
"dan mengumpul maklum balas peserta melalui soalselidik dalam talian."
|
||||||
|
)
|
||||||
|
doc.add_paragraph()
|
||||||
|
add_body("Fungsi utama sistem:")
|
||||||
|
add_bullet("Pengurusan program dan peserta")
|
||||||
|
add_bullet("Check-in peserta melalui kod QR")
|
||||||
|
add_bullet("Jana dan hantar sijil digital secara automatik")
|
||||||
|
add_bullet("Pengurusan template sijil")
|
||||||
|
add_bullet("Kutipan maklum balas melalui soalselidik")
|
||||||
|
add_bullet("Laporan statistik kehadiran dan penyertaan")
|
||||||
|
doc.add_paragraph()
|
||||||
|
add_body("Manual ini ditujukan kepada pentadbir sistem (Admin dan Super Admin) untuk menggunakan semua fungsi yang tersedia.")
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 2: LOG MASUK
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("2. Log Masuk ke Sistem")
|
||||||
|
add_body("Untuk mengakses sistem eCert MBIP, pentadbir perlu log masuk menggunakan alamat emel dan kata laluan yang telah diberikan.")
|
||||||
|
doc.add_paragraph()
|
||||||
|
add_body("Langkah-langkah log masuk:")
|
||||||
|
add_bullet(f"Buka pelayar web dan pergi ke: {BASE_URL}/login")
|
||||||
|
add_bullet("Masukkan Alamat Emel yang berdaftar.")
|
||||||
|
add_bullet("Masukkan Kata Laluan.")
|
||||||
|
add_bullet("Klik butang Log Masuk.")
|
||||||
|
doc.add_paragraph()
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Halaman Log Masuk — Borang emel dan kata laluan",
|
||||||
|
f"{BASE_URL}/login"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_note(
|
||||||
|
"Jika terlupa kata laluan, klik pautan 'Terlupa Kata Laluan?' di bawah borang log masuk. "
|
||||||
|
"Pautan set semula kata laluan akan dihantar ke alamat emel yang didaftarkan."
|
||||||
|
)
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 3: DASHBOARD
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("3. Dashboard Utama")
|
||||||
|
add_body(
|
||||||
|
"Selepas log masuk, pentadbir akan dibawa ke halaman Dashboard. Dashboard memaparkan ringkasan aktiviti sistem "
|
||||||
|
"termasuk jumlah program aktif, jumlah peserta, dan sijil yang dijana."
|
||||||
|
)
|
||||||
|
doc.add_paragraph()
|
||||||
|
add_body("Elemen pada Dashboard:")
|
||||||
|
add_bullet("Jumlah program yang sedang aktif")
|
||||||
|
add_bullet("Senarai program terkini")
|
||||||
|
add_bullet("Pautan pantas ke fungsi utama")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Dashboard Utama — Ringkasan statistik dan senarai program",
|
||||||
|
f"{BASE_URL}/admin/dashboard"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_body("Bar navigasi di sebelah kiri (sidebar) menyediakan akses pantas ke semua modul sistem.")
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 4: PENGURUSAN PROGRAM
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("4. Pengurusan Program")
|
||||||
|
add_body(
|
||||||
|
"Modul Program adalah teras sistem eCert MBIP. Setiap program mewakili satu acara atau kursus yang dianjurkan. "
|
||||||
|
"Pentadbir boleh mencipta, mengemaskini, dan mengurus status program dari modul ini."
|
||||||
|
)
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Senarai Program — Semua program yang telah dicipta",
|
||||||
|
f"{BASE_URL}/admin/programs"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4.1
|
||||||
|
add_heading("4.1 Cipta Program Baru", level=2)
|
||||||
|
add_body("Langkah-langkah mencipta program baru:")
|
||||||
|
add_bullet("Klik butang + Cipta Program di halaman Senarai Program.")
|
||||||
|
add_bullet("Isi maklumat program:")
|
||||||
|
|
||||||
|
fields_program = [
|
||||||
|
("Tajuk Program", "Nama program atau acara (wajib)"),
|
||||||
|
("Penerangan", "Huraian ringkas program"),
|
||||||
|
("Tarikh Mula / Tamat", "Tarikh pelaksanaan program"),
|
||||||
|
("Lokasi", "Tempat program diadakan"),
|
||||||
|
("Benarkan Walk-in", "Aktifkan jika peserta luar dibenarkan daftar semasa check-in"),
|
||||||
|
]
|
||||||
|
for field, desc in fields_program:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.left_indent = Cm(1)
|
||||||
|
r1 = p.add_run(f"{field}: ")
|
||||||
|
r1.bold = True
|
||||||
|
r1.font.size = Pt(11)
|
||||||
|
p.add_run(desc).font.size = Pt(11)
|
||||||
|
|
||||||
|
add_bullet("Klik Simpan untuk menyimpan program.")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Borang Cipta Program Baru — Isi maklumat program",
|
||||||
|
f"{BASE_URL}/admin/programs/create"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4.2
|
||||||
|
add_heading("4.2 Kemaskini Maklumat Program", level=2)
|
||||||
|
add_body(
|
||||||
|
"Untuk mengedit program sedia ada, klik ikon Edit (pensel) pada senarai program atau klik nama program "
|
||||||
|
"kemudian pilih tab Butiran."
|
||||||
|
)
|
||||||
|
add_screenshot_box(
|
||||||
|
"Halaman Butiran Program — Tab maklumat, peserta, template, soalselidik, sijil",
|
||||||
|
f"{BASE_URL}/admin/programs/{{uuid}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4.3
|
||||||
|
add_heading("4.3 Tetapan Check-in dan Muat Turun", level=2)
|
||||||
|
add_body("Pentadbir perlu menetapkan waktu check-in dan tempoh muat turun sijil dalam tetapan program:")
|
||||||
|
|
||||||
|
settings_table = [
|
||||||
|
("Mula Check-in", "Tarikh dan masa check-in dibuka untuk peserta"),
|
||||||
|
("Tamat Check-in", "Tarikh dan masa check-in ditutup"),
|
||||||
|
("Mula Muat Turun Sijil", "Peserta boleh muat turun sijil selepas tempoh ini"),
|
||||||
|
("Tamat Muat Turun Sijil", "Tempoh muat turun sijil tamat"),
|
||||||
|
]
|
||||||
|
for field, desc in settings_table:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.left_indent = Cm(1)
|
||||||
|
r1 = p.add_run(f"{field}: ")
|
||||||
|
r1.bold = True
|
||||||
|
r1.font.size = Pt(11)
|
||||||
|
p.add_run(desc).font.size = Pt(11)
|
||||||
|
|
||||||
|
add_note("Semua masa menggunakan waktu Malaysia (MYT, UTC+8).")
|
||||||
|
|
||||||
|
# 4.4
|
||||||
|
add_heading("4.4 Publish dan Tutup Program", level=2)
|
||||||
|
add_body("Program perlu di-publish sebelum peserta dapat menggunakan pautan check-in QR.")
|
||||||
|
add_bullet("Klik butang Publish untuk mengaktifkan program. Status bertukar kepada Aktif.")
|
||||||
|
add_bullet("Klik butang Tutup untuk menamatkan program. Peserta tidak lagi dapat check-in.")
|
||||||
|
add_note("Program yang telah ditutup tidak boleh dibuka semula secara automatik.")
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 5: PENGURUSAN PESERTA
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("5. Pengurusan Peserta")
|
||||||
|
add_body("Modul Peserta membolehkan pentadbir mengurus senarai peserta bagi setiap program.")
|
||||||
|
|
||||||
|
# 5.1
|
||||||
|
add_heading("5.1 Lihat Senarai Peserta", level=2)
|
||||||
|
add_body("Klik tab Peserta dalam halaman butiran program untuk melihat semua peserta berdaftar.")
|
||||||
|
add_body("Maklumat yang dipaparkan:")
|
||||||
|
add_bullet("Nama peserta")
|
||||||
|
add_bullet("No. Kad Pengenalan")
|
||||||
|
add_bullet("Status check-in (Hadir / Belum Hadir)")
|
||||||
|
add_bullet("Status sijil (Belum Jana / Dijana / Dihantar)")
|
||||||
|
add_bullet("Sumber pendaftaran (Pra-daftar / Walk-in)")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Senarai Peserta — Status kehadiran dan sijil setiap peserta",
|
||||||
|
f"{BASE_URL}/admin/programs/{{uuid}}/participants"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5.2
|
||||||
|
add_heading("5.2 Tambah Peserta Satu-Satu", level=2)
|
||||||
|
add_body("Untuk menambah peserta secara manual:")
|
||||||
|
add_bullet("Klik butang + Tambah Peserta.")
|
||||||
|
add_bullet("Isi Nama Penuh dan No. Kad Pengenalan (12 digit).")
|
||||||
|
add_bullet("Isi maklumat tambahan jika perlu (emel, telefon, agensi).")
|
||||||
|
add_bullet("Klik Simpan.")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Borang Tambah Peserta — Isi maklumat peserta",
|
||||||
|
f"{BASE_URL}/admin/programs/{{uuid}}/participants/create"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5.3
|
||||||
|
add_heading("5.3 Import Peserta dari Excel", level=2)
|
||||||
|
add_body("Untuk mendaftar ramai peserta sekaligus:")
|
||||||
|
add_bullet("Klik butang Import Excel.")
|
||||||
|
add_bullet("Muat turun templat Excel yang disediakan.")
|
||||||
|
add_bullet("Isi maklumat peserta dalam templat (Nama, No. KP, Emel, Telefon, Agensi).")
|
||||||
|
add_bullet("Muat naik semula fail Excel yang telah diisi.")
|
||||||
|
add_bullet("Semak ringkasan import dan klik Sahkan.")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Halaman Import Peserta — Muat naik fail Excel",
|
||||||
|
f"{BASE_URL}/admin/programs/{{uuid}}/participants/import"
|
||||||
|
)
|
||||||
|
add_note("Sistem akan abaikan baris yang No. KP-nya sudah wujud dalam program yang sama.")
|
||||||
|
|
||||||
|
# 5.4
|
||||||
|
add_heading("5.4 Export Senarai Peserta", level=2)
|
||||||
|
add_body("Klik butang Export Excel untuk memuat turun senarai lengkap peserta beserta status check-in dan sijil ke dalam fail Excel.")
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 6: KOD QR CHECK-IN
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("6. Kod QR Check-in")
|
||||||
|
add_body(
|
||||||
|
"Setiap program mempunyai Kod QR unik yang digunakan peserta untuk check-in. "
|
||||||
|
"Peserta mengimbas kod ini menggunakan telefon pintar untuk mendaftarkan kehadiran."
|
||||||
|
)
|
||||||
|
add_body("Cara menjana dan menggunakan Kod QR:")
|
||||||
|
add_bullet("Klik tab Kod QR dalam halaman butiran program.")
|
||||||
|
add_bullet("Klik Jana QR Code jika belum dijana.")
|
||||||
|
add_bullet("Paparkan Kod QR pada skrin besar atau cetak untuk peserta mengimbas.")
|
||||||
|
add_bullet("Klik Muat Turun untuk menyimpan imej Kod QR.")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Halaman Kod QR — Jana, papar, dan muat turun QR Code",
|
||||||
|
f"{BASE_URL}/admin/programs/{{uuid}}/qr"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_note(
|
||||||
|
"Kod QR boleh dinyahaktifkan (Deactivate) dan dijana semula jika diperlukan. "
|
||||||
|
"Kod lama tidak akan berfungsi selepas dinyahaktifkan."
|
||||||
|
)
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 7: TEMPLATE SIJIL
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("7. Template Sijil")
|
||||||
|
add_body(
|
||||||
|
"Modul Template Sijil membolehkan pentadbir menyediakan reka bentuk sijil yang akan digunakan "
|
||||||
|
"untuk menjana sijil digital peserta."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7.1
|
||||||
|
add_heading("7.1 Muat Naik Template", level=2)
|
||||||
|
add_body("Langkah-langkah muat naik template sijil:")
|
||||||
|
add_bullet("Klik tab Template Sijil dalam halaman butiran program.")
|
||||||
|
add_bullet("Klik butang Pilih Fail dan pilih imej template (format JPG atau PNG, maksimum 10MB).")
|
||||||
|
add_bullet("Resolusi disyorkan: 1754 × 1240 piksel (A4 landscape) atau 1240 × 1754 piksel (portrait).")
|
||||||
|
add_bullet("Klik Muat Naik.")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Halaman Muat Naik Template — Pilih fail imej sijil",
|
||||||
|
f"{BASE_URL}/admin/programs/{{uuid}}/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7.2
|
||||||
|
add_heading("7.2 Konfigurasi Kedudukan Teks", level=2)
|
||||||
|
add_body(
|
||||||
|
"Selepas template dimuat naik, pentadbir perlu menetapkan kedudukan teks pada sijil. "
|
||||||
|
"Koordinat dikira dari sudut kiri atas imej (piksel)."
|
||||||
|
)
|
||||||
|
add_body("Medan yang boleh dikonfigurasi:")
|
||||||
|
|
||||||
|
config_fields = [
|
||||||
|
("Nama Peserta", "Kedudukan X, Y, saiz font, warna, dan penjajaran (kiri/tengah/kanan)"),
|
||||||
|
("No. IC", "Saiz font No. IC yang dipaparkan di bawah nama"),
|
||||||
|
("No. Sijil (Pilihan)", "Aktifkan togol 'Papar' untuk menambah No. Sijil pada sijil. Tetapkan kedudukan X, Y."),
|
||||||
|
]
|
||||||
|
for field, desc in config_fields:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.left_indent = Cm(1)
|
||||||
|
r1 = p.add_run(f"{field}: ")
|
||||||
|
r1.bold = True
|
||||||
|
r1.font.size = Pt(11)
|
||||||
|
p.add_run(desc).font.size = Pt(11)
|
||||||
|
|
||||||
|
add_bullet("Klik Simpan Konfigurasi untuk menyimpan tetapan.")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Konfigurasi Template — Tetapkan kedudukan teks nama dan No. Sijil",
|
||||||
|
f"{BASE_URL}/admin/programs/{{uuid}}/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7.3
|
||||||
|
add_heading("7.3 Jana Pratonton Sijil", level=2)
|
||||||
|
add_body("Untuk menyemak kedudukan teks sebelum menjana sijil sebenar:")
|
||||||
|
add_bullet("Masukkan nama contoh dalam kotak Jana Pratonton.")
|
||||||
|
add_bullet("Klik butang Pratonton.")
|
||||||
|
add_bullet("Imej pratonton akan dipaparkan dengan teks pada koordinat yang ditetapkan.")
|
||||||
|
add_note("Pratonton menggunakan nilai koordinat terkini walaupun belum disimpan.")
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 8: SOALSELIDIK PROGRAM
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("8. Soalselidik Program")
|
||||||
|
add_body(
|
||||||
|
"Pentadbir boleh mengaitkan set soalselidik dengan program. Peserta akan diminta mengisi soalselidik "
|
||||||
|
"selepas check-in sebelum sijil boleh dimuat turun."
|
||||||
|
)
|
||||||
|
add_body("Cara mengaitkan soalselidik dengan program:")
|
||||||
|
add_bullet("Klik tab Soalselidik dalam halaman butiran program.")
|
||||||
|
add_bullet("Pilih Set Soalselidik yang ingin digunakan daripada senarai tersedia.")
|
||||||
|
add_bullet("Klik Lampirkan Soalselidik.")
|
||||||
|
add_bullet("Klik Sahkan untuk mengesahkan penggunaan soalselidik ini.")
|
||||||
|
add_bullet("Klik Pratonton untuk melihat soalan yang akan dijawab peserta.")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Halaman Soalselidik Program — Lampirkan dan pratonton soalselidik",
|
||||||
|
f"{BASE_URL}/admin/programs/{{uuid}}/questionnaire"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_note("Soalselidik yang telah disahkan tidak boleh ditukar. Sah kan hanya apabila sudah pasti.")
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 9: PENGURUSAN SIJIL
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("9. Pengurusan Sijil")
|
||||||
|
add_body(
|
||||||
|
"Modul Sijil membolehkan pentadbir menjana dan menghantar sijil digital kepada semua peserta yang hadir."
|
||||||
|
)
|
||||||
|
add_body("Fungsi yang tersedia:")
|
||||||
|
add_bullet("Jana Semua Sijil — Menjana sijil untuk semua peserta yang telah check-in.")
|
||||||
|
add_bullet("Hantar Emel Semua — Menghantar sijil kepada peserta melalui emel secara pukal.")
|
||||||
|
add_body("Status sijil setiap peserta:")
|
||||||
|
|
||||||
|
status_sijil = [
|
||||||
|
("Belum Jana", "Sijil belum dijana untuk peserta ini"),
|
||||||
|
("Dijana", "Sijil sudah dijana dan sedia untuk dihantar"),
|
||||||
|
("Dihantar", "Sijil telah dihantar melalui emel"),
|
||||||
|
("Dimuat Turun", "Peserta telah memuat turun sijil mereka"),
|
||||||
|
]
|
||||||
|
for status, desc in status_sijil:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.left_indent = Cm(1)
|
||||||
|
r1 = p.add_run(f"{status}: ")
|
||||||
|
r1.bold = True
|
||||||
|
r1.font.size = Pt(11)
|
||||||
|
p.add_run(desc).font.size = Pt(11)
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Halaman Sijil — Senarai sijil dan fungsi jana/hantar pukal",
|
||||||
|
f"{BASE_URL}/admin/programs/{{uuid}}/certificates"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_note("Pastikan template sijil telah dikonfigurasi terlebih dahulu sebelum menjana sijil.")
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 10: STATISTIK PROGRAM
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("10. Statistik Program")
|
||||||
|
add_body(
|
||||||
|
"Halaman Statistik memaparkan data analitik terperinci bagi setiap program. "
|
||||||
|
"Pentadbir boleh memantau prestasi program melalui laporan yang disediakan."
|
||||||
|
)
|
||||||
|
add_body("Data yang dipaparkan:")
|
||||||
|
add_bullet("Jumlah peserta berdaftar vs. jumlah yang hadir")
|
||||||
|
add_bullet("Pecahan mengikut sesi (Slot masa check-in)")
|
||||||
|
add_bullet("Pecahan mengikut sumber pendaftaran (Pra-daftar / Walk-in)")
|
||||||
|
add_bullet("Status sijil (Dijana, Dihantar, Dimuat Turun)")
|
||||||
|
add_bullet("Keputusan soalselidik (jika soalselidik dikaitkan)")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Halaman Statistik — Graf dan data analitik program",
|
||||||
|
f"{BASE_URL}/admin/programs/{{uuid}}/statistics"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_body("Klik butang Export Excel untuk memuat turun laporan statistik dalam format Excel.")
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 11: SET SOALSELIDIK
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("11. Set Soalselidik")
|
||||||
|
add_body(
|
||||||
|
"Modul Set Soalselidik membolehkan pentadbir membina borang soalan yang boleh digunakan semula "
|
||||||
|
"merentasi pelbagai program."
|
||||||
|
)
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Senarai Set Soalselidik — Semua set yang telah dicipta",
|
||||||
|
f"{BASE_URL}/admin/questionnaires"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 11.1
|
||||||
|
add_heading("11.1 Cipta Set Soalselidik", level=2)
|
||||||
|
add_body("Langkah-langkah mencipta set soalselidik baru:")
|
||||||
|
add_bullet("Klik butang + Cipta Set Soalselidik.")
|
||||||
|
add_bullet("Masukkan Nama Set dan Penerangan (pilihan).")
|
||||||
|
add_bullet("Klik Simpan.")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Borang Cipta Set Soalselidik — Nama dan penerangan",
|
||||||
|
f"{BASE_URL}/admin/questionnaires/create"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 11.2
|
||||||
|
add_heading("11.2 Tambah dan Urus Soalan", level=2)
|
||||||
|
add_body("Selepas set dicipta, tambah soalan melalui halaman butiran set soalselidik.")
|
||||||
|
add_body("Jenis soalan yang tersedia:")
|
||||||
|
|
||||||
|
jenis_soalan = [
|
||||||
|
("Tajuk (Seksyen)", "Pengepala bahagian — boleh menjadi parent kepada soalan Rating"),
|
||||||
|
("Rating (1-5)", "Penilaian skala 1 hingga 5 — mesti diletakkan di bawah Tajuk"),
|
||||||
|
("Pilihan Tunggal", "Peserta pilih satu jawapan sahaja"),
|
||||||
|
("Pilihan Berganda", "Peserta boleh pilih lebih dari satu jawapan"),
|
||||||
|
("Teks Pendek", "Jawapan dalam satu baris"),
|
||||||
|
("Teks Panjang", "Jawapan berbilang baris"),
|
||||||
|
]
|
||||||
|
for jenis, desc in jenis_soalan:
|
||||||
|
p = doc.add_paragraph()
|
||||||
|
p.paragraph_format.left_indent = Cm(1)
|
||||||
|
r1 = p.add_run(f"{jenis}: ")
|
||||||
|
r1.bold = True
|
||||||
|
r1.font.size = Pt(11)
|
||||||
|
p.add_run(desc).font.size = Pt(11)
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Halaman Set Soalselidik — Senarai soalan dan borang tambah soalan",
|
||||||
|
f"{BASE_URL}/admin/questionnaires/{{id}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_body("Ciri-ciri tambahan:")
|
||||||
|
add_bullet("Soalan Rating: Pentadbir boleh tetapkan label teks untuk setiap nilai (1-5) pada peringkat Tajuk.")
|
||||||
|
add_bullet("Susunan soalan boleh diubah dengan seret-dan-lepas (drag-and-drop).")
|
||||||
|
add_bullet("Soalan Rating tidak boleh dipindahkan keluar dari Tajuk induknya.")
|
||||||
|
add_bullet("Tetapkan soalan sebagai Wajib atau tidak wajib.")
|
||||||
|
|
||||||
|
add_note(
|
||||||
|
"Set soalselidik perlu di-Publish sebelum boleh dikaitkan dengan program. "
|
||||||
|
"Selepas di-Publish, soalan tidak boleh diubah."
|
||||||
|
)
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 12: PENGURUSAN PENGGUNA (SUPER ADMIN)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("12. Pengurusan Pengguna")
|
||||||
|
add_body(
|
||||||
|
"Modul ini hanya boleh diakses oleh Super Admin. "
|
||||||
|
"Super Admin boleh mencipta dan mengurus akaun pentadbir lain dalam sistem."
|
||||||
|
)
|
||||||
|
add_body("Fungsi yang tersedia:")
|
||||||
|
add_bullet("Lihat senarai semua pentadbir")
|
||||||
|
add_bullet("Cipta akaun pentadbir baru")
|
||||||
|
add_bullet("Kemaskini maklumat pentadbir")
|
||||||
|
add_bullet("Padam akaun pentadbir")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Halaman Pengurusan Pengguna — Senarai pentadbir (Super Admin sahaja)",
|
||||||
|
f"{BASE_URL}/admin/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_body("Jenis peranan pengguna:")
|
||||||
|
add_bullet("Super Admin — Akses penuh termasuk Pengurusan Pengguna")
|
||||||
|
add_bullet("Admin Program — Akses kepada semua program dan soalselidik, kecuali Pengurusan Pengguna")
|
||||||
|
|
||||||
|
add_note("Setiap sistem perlu sekurang-kurangnya satu akaun Super Admin yang aktif.")
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# BAB 13: PROFIL PENGGUNA
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("13. Profil Pengguna")
|
||||||
|
add_body(
|
||||||
|
"Setiap pentadbir boleh mengurus maklumat akaun peribadi mereka melalui halaman Profil. "
|
||||||
|
"Klik nama pengguna atau butang Profil di bar navigasi kiri untuk mengakses halaman ini."
|
||||||
|
)
|
||||||
|
add_body("Fungsi yang tersedia:")
|
||||||
|
|
||||||
|
add_heading("Tukar Alamat Emel", level=2)
|
||||||
|
add_bullet("Masukkan Kata Laluan Semasa untuk pengesahan.")
|
||||||
|
add_bullet("Masukkan Emel Baru.")
|
||||||
|
add_bullet("Klik Kemaskini Emel.")
|
||||||
|
|
||||||
|
add_heading("Tukar Kata Laluan", level=2)
|
||||||
|
add_bullet("Masukkan Kata Laluan Semasa.")
|
||||||
|
add_bullet("Masukkan Kata Laluan Baru (minimum 8 aksara).")
|
||||||
|
add_bullet("Masukkan semula Kata Laluan Baru untuk pengesahan.")
|
||||||
|
add_bullet("Klik Tukar Kata Laluan.")
|
||||||
|
|
||||||
|
add_screenshot_box(
|
||||||
|
"Halaman Profil — Tukar emel dan kata laluan",
|
||||||
|
f"{BASE_URL}/admin/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
add_note("Kata laluan baru mestilah sekurang-kurangnya 8 aksara. Simpan kata laluan di tempat yang selamat.")
|
||||||
|
page_break()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# LAMPIRAN: ALIRAN KERJA SISTEM
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
add_heading("Lampiran: Aliran Kerja Tipikal")
|
||||||
|
add_body("Berikut adalah urutan langkah yang disyorkan untuk menjalankan sebuah program dari mula hingga selesai:")
|
||||||
|
|
||||||
|
workflow = [
|
||||||
|
("1", "Cipta Set Soalselidik", "Bina soalan maklum balas di Modul Set Soalselidik dan publish."),
|
||||||
|
("2", "Cipta Program", "Isi maklumat program, tarikh, dan tetapan check-in."),
|
||||||
|
("3", "Import Peserta", "Muat naik senarai peserta melalui Excel."),
|
||||||
|
("4", "Muat Naik Template Sijil", "Upload reka bentuk sijil dan konfigurasi kedudukan teks."),
|
||||||
|
("5", "Lampirkan Soalselidik", "Kaitkan set soalselidik dengan program dan sahkan."),
|
||||||
|
("6", "Publish Program", "Aktifkan program supaya peserta boleh check-in."),
|
||||||
|
("7", "Jana & Papar Kod QR", "Paparkan QR Code semasa acara untuk peserta mengimbas."),
|
||||||
|
("8", "Pantau Statistik", "Semak kehadiran dan maklum balas dalam masa nyata."),
|
||||||
|
("9", "Jana dan Hantar Sijil", "Selepas program tamat, jana sijil dan hantar melalui emel."),
|
||||||
|
]
|
||||||
|
|
||||||
|
tbl = doc.add_table(rows=1, cols=3)
|
||||||
|
tbl.style = 'Table Grid'
|
||||||
|
hdr = tbl.rows[0].cells
|
||||||
|
hdr[0].text = "Langkah"
|
||||||
|
hdr[1].text = "Tindakan"
|
||||||
|
hdr[2].text = "Penerangan"
|
||||||
|
for cell in hdr:
|
||||||
|
cell.paragraphs[0].runs[0].bold = True
|
||||||
|
cell.paragraphs[0].runs[0].font.size = Pt(10)
|
||||||
|
|
||||||
|
for step, action, desc in workflow:
|
||||||
|
row = tbl.add_row().cells
|
||||||
|
row[0].text = step
|
||||||
|
row[1].text = action
|
||||||
|
row[2].text = desc
|
||||||
|
for cell in row:
|
||||||
|
cell.paragraphs[0].runs[0].font.size = Pt(10)
|
||||||
|
|
||||||
|
doc.add_paragraph()
|
||||||
|
add_body(f"Untuk sokongan teknikal, hubungi pentadbir sistem atau lawati: {BASE_URL}")
|
||||||
|
|
||||||
|
# ── Simpan dokumen ─────────────────────────────────────────────────────────────
|
||||||
|
output_path = r"C:\Users\User\Aplikasi\ecert\manual\Manual_Pengguna_eCert_MBIP.docx"
|
||||||
|
doc.save(output_path)
|
||||||
|
print(f"Manual berjaya dijana: {output_path}")
|
||||||
2046
package-lock.json
generated
2046
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
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 App\Models\Program;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class CertificateController extends Controller
|
class CertificateController extends Controller
|
||||||
@@ -97,6 +99,31 @@ class CertificateController extends Controller
|
|||||||
return back()->with('success', "Penghantaran emel sijil dijadualkan untuk {$toEmail} peserta.");
|
return back()->with('success', "Penghantaran emel sijil dijadualkan untuk {$toEmail} peserta.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function download(Program $program, Certificate $certificate): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
if ($certificate->program_id !== $program->id) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $certificate->isGenerated()) {
|
||||||
|
return back()->with('error', 'Sijil belum sedia untuk dimuat turun.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $certificate->file_path || ! Storage::disk('local')->exists($certificate->file_path)) {
|
||||||
|
return back()->with('error', 'Fail sijil tidak dijumpai.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$certificate->loadMissing('participant');
|
||||||
|
$content = Storage::disk('local')->get($certificate->file_path);
|
||||||
|
$filename = 'Sijil-' . str($certificate->participant->name)->slug() . '.jpg';
|
||||||
|
|
||||||
|
return response($content, 200, [
|
||||||
|
'Content-Type' => 'image/jpeg',
|
||||||
|
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||||
|
'Content-Length' => strlen($content),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private function buildCertNo(Program $program, int $seq): string
|
private function buildCertNo(Program $program, int $seq): string
|
||||||
{
|
{
|
||||||
$year = now()->format('Y');
|
$year = now()->format('Y');
|
||||||
@@ -60,9 +60,15 @@ class CertificateTemplateController extends Controller
|
|||||||
'fields.*.align' => 'required|in:left,center,right',
|
'fields.*.align' => 'required|in:left,center,right',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$config = $template->config_json ?? [];
|
$config = $template->config_json ?? [];
|
||||||
$config['fields'] = array_merge($config['fields'] ?? [], $request->fields);
|
$merged = array_merge($config['fields'] ?? [], $request->fields);
|
||||||
|
|
||||||
|
// Kalau toggle No. Sijil dimatikan, buang dari config
|
||||||
|
if (! $request->boolean('show_cert_no')) {
|
||||||
|
unset($merged['certificate_no']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$config['fields'] = $merged;
|
||||||
$template->update(['config_json' => $config]);
|
$template->update(['config_json' => $config]);
|
||||||
|
|
||||||
return redirect()->route('admin.programs.template.show', $program)
|
return redirect()->route('admin.programs.template.show', $program)
|
||||||
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;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Certificate;
|
||||||
use App\Models\Participant;
|
use App\Models\Participant;
|
||||||
use App\Models\Program;
|
use App\Models\Program;
|
||||||
use App\Models\ProgramParticipant;
|
use App\Models\ProgramParticipant;
|
||||||
@@ -40,6 +41,13 @@ class ParticipantController extends Controller
|
|||||||
|
|
||||||
$programParticipants = $query->paginate(20)->withQueryString();
|
$programParticipants = $query->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
// Load certificates for displayed participants
|
||||||
|
$participantIds = $programParticipants->pluck('participant_id');
|
||||||
|
$certificates = Certificate::where('program_id', $program->id)
|
||||||
|
->whereIn('participant_id', $participantIds)
|
||||||
|
->get()
|
||||||
|
->keyBy('participant_id');
|
||||||
|
|
||||||
$countRow = DB::table('program_participants')
|
$countRow = DB::table('program_participants')
|
||||||
->where('program_id', $program->id)
|
->where('program_id', $program->id)
|
||||||
->selectRaw("COUNT(*) as total, SUM(is_pre_registered) as pre_registered, SUM(registration_source = 'walk_in') as walk_in, SUM(status = 'checked_in') as checked_in")
|
->selectRaw("COUNT(*) as total, SUM(is_pre_registered) as pre_registered, SUM(registration_source = 'walk_in') as walk_in, SUM(status = 'checked_in') as checked_in")
|
||||||
@@ -52,7 +60,7 @@ class ParticipantController extends Controller
|
|||||||
'checked_in' => (int) ($countRow->checked_in ?? 0),
|
'checked_in' => (int) ($countRow->checked_in ?? 0),
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('admin.programs.participants.index', compact('program', 'programParticipants', 'counts'));
|
return view('admin.programs.participants.index', compact('program', 'programParticipants', 'counts', 'certificates'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(Program $program): View
|
public function create(Program $program): View
|
||||||
@@ -104,6 +112,58 @@ class ParticipantController extends Controller
|
|||||||
return back()->with('success', 'Peserta berjaya ditambah.');
|
return back()->with('success', 'Peserta berjaya ditambah.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit(Program $program, ProgramParticipant $pp, Request $request): View
|
||||||
|
{
|
||||||
|
if ($pp->program_id !== $program->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pp->load('participant');
|
||||||
|
$filters = $request->only(['search', 'source', 'status', 'page']);
|
||||||
|
|
||||||
|
return view('admin.programs.participants.edit', compact('program', 'pp', 'filters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Program $program, ProgramParticipant $pp, Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($pp->program_id !== $program->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'no_kp' => ['required', 'string', 'regex:/^\d{12}$/', 'unique:participants,no_kp,' . $pp->participant_id],
|
||||||
|
'email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:20'],
|
||||||
|
'agency' => ['nullable', 'string', 'max:255'],
|
||||||
|
'session' => ['nullable', 'in:pagi,petang,full_day'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pp->load('participant');
|
||||||
|
|
||||||
|
DB::transaction(function () use ($pp, $request) {
|
||||||
|
$pp->participant->update([
|
||||||
|
'name' => $request->name,
|
||||||
|
'no_kp' => preg_replace('/[^0-9]/', '', $request->no_kp),
|
||||||
|
'email' => $request->email ?: null,
|
||||||
|
'phone' => $request->phone ?: null,
|
||||||
|
'agency' => $request->agency ?: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pp->update([
|
||||||
|
'pre_registered_session' => $request->session ?: null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
AuditLogService::log('participant.updated', $pp->participant);
|
||||||
|
|
||||||
|
$filters = array_filter($request->only(['search', 'source', 'status', 'page']));
|
||||||
|
$indexUrl = route('admin.programs.participants.index', $program)
|
||||||
|
. ($filters ? '?' . http_build_query($filters) : '');
|
||||||
|
|
||||||
|
return redirect($indexUrl)->with('success', 'Maklumat peserta berjaya dikemaskini.');
|
||||||
|
}
|
||||||
|
|
||||||
public function destroy(Program $program, ProgramParticipant $pp): RedirectResponse
|
public function destroy(Program $program, ProgramParticipant $pp): RedirectResponse
|
||||||
{
|
{
|
||||||
if ($pp->program_id !== $program->id) {
|
if ($pp->program_id !== $program->id) {
|
||||||
@@ -121,31 +181,52 @@ class ParticipantController extends Controller
|
|||||||
|
|
||||||
public function importForm(Program $program): View
|
public function importForm(Program $program): View
|
||||||
{
|
{
|
||||||
return view('admin.programs.participants.import', compact('program'));
|
$cutoff = $program->checkin_end_at ?? $program->end_date->endOfDay();
|
||||||
|
$programEnded = now()->gt($cutoff);
|
||||||
|
|
||||||
|
return view('admin.programs.participants.import', compact('program', 'programEnded'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function import(Program $program, Request $request, ParticipantImportService $importer): RedirectResponse
|
public function import(Program $program, Request $request, ParticipantImportService $importer): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'csv_file' => ['required', 'file', 'mimes:csv,txt', 'max:5120'],
|
'csv_file' => ['required', 'file', 'mimes:csv,txt', 'max:5120'],
|
||||||
'session' => ['nullable', 'in:pagi,petang,full_day'],
|
'session' => ['nullable', 'in:pagi,petang,full_day'],
|
||||||
|
'mark_attendance' => ['nullable', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$cutoff = $program->checkin_end_at ?? $program->end_date->endOfDay();
|
||||||
|
$markAttendance = now()->gt($cutoff) && $request->boolean('mark_attendance');
|
||||||
|
|
||||||
$result = $importer->import(
|
$result = $importer->import(
|
||||||
$program,
|
$program,
|
||||||
$request->file('csv_file'),
|
$request->file('csv_file'),
|
||||||
$request->input('session', $program->default_staff_session)
|
$request->input('session', $program->default_staff_session),
|
||||||
|
$markAttendance
|
||||||
);
|
);
|
||||||
|
|
||||||
AuditLogService::log('participant.imported', $program, [], [
|
AuditLogService::log('participant.imported', $program, [], [
|
||||||
'success' => $result['success'],
|
'success' => $result['success'],
|
||||||
'duplicates' => $result['duplicates'],
|
'duplicates' => $result['duplicates'],
|
||||||
'failed' => $result['failed'],
|
'failed' => $result['failed'],
|
||||||
|
'mark_attendance'=> $markAttendance,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return back()->with('import_result', $result);
|
return back()->with('import_result', $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function clearParticipants(Program $program): RedirectResponse
|
||||||
|
{
|
||||||
|
$deleted = $program->programParticipants()
|
||||||
|
->where('status', '!=', 'checked_in')
|
||||||
|
->whereDoesntHave('attendance')
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.programs.participants.import.form', $program)
|
||||||
|
->with('success', "{$deleted} rekod peserta (belum hadir) telah dipadam.");
|
||||||
|
}
|
||||||
|
|
||||||
public function export(Program $program): \Symfony\Component\HttpFoundation\StreamedResponse
|
public function export(Program $program): \Symfony\Component\HttpFoundation\StreamedResponse
|
||||||
{
|
{
|
||||||
$headers = [
|
$headers = [
|
||||||
@@ -9,6 +9,8 @@ use App\Models\Program;
|
|||||||
use App\Services\AuditLogService;
|
use App\Services\AuditLogService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class ProgramController extends Controller
|
class ProgramController extends Controller
|
||||||
@@ -78,16 +80,30 @@ class ProgramController extends Controller
|
|||||||
|
|
||||||
$certStats = \DB::table('certificates')
|
$certStats = \DB::table('certificates')
|
||||||
->where('program_id', $program->id)
|
->where('program_id', $program->id)
|
||||||
->selectRaw("COUNT(*) as total, SUM(status IN ('generated','emailed','downloaded')) as cert_generated")
|
->selectRaw("COUNT(*) as total, SUM(status IN ('generated','emailed','downloaded')) as cert_generated, SUM(download_count) as total_downloads, SUM(status = 'downloaded') as downloaded")
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
// Sijil dijana tapi belum dihantar emel (ada emel peserta)
|
||||||
|
$emailsPending = \DB::table('certificates')
|
||||||
|
->join('participants', 'participants.id', '=', 'certificates.participant_id')
|
||||||
|
->where('certificates.program_id', $program->id)
|
||||||
|
->where('certificates.status', 'generated')
|
||||||
|
->whereNull('certificates.emailed_at')
|
||||||
|
->whereNotNull('participants.email')
|
||||||
|
->count();
|
||||||
|
|
||||||
$stats = [
|
$stats = [
|
||||||
'total_participants' => (int) ($ppStats->total ?? 0),
|
'total_participants' => (int) ($ppStats->total ?? 0),
|
||||||
'pre_registered' => (int) ($ppStats->pre_registered ?? 0),
|
'pre_registered' => (int) ($ppStats->pre_registered ?? 0),
|
||||||
'walk_in' => (int) ($ppStats->walk_in ?? 0),
|
'walk_in' => (int) ($ppStats->walk_in ?? 0),
|
||||||
'total_attendances' => $program->attendances()->count(),
|
'total_attendances' => $program->attendances()->count(),
|
||||||
'total_certificates' => (int) ($certStats->total ?? 0),
|
'total_certificates' => (int) ($certStats->total ?? 0),
|
||||||
'generated_certificates' => (int) ($certStats->cert_generated ?? 0),
|
'generated_certificates' => (int) ($certStats->cert_generated ?? 0),
|
||||||
|
'downloaded_certificates'=> (int) ($certStats->downloaded ?? 0),
|
||||||
|
'total_downloads' => (int) ($certStats->total_downloads ?? 0),
|
||||||
|
'emails_pending' => $emailsPending,
|
||||||
|
'emails_sent' => (int) \DB::table('program_participants')->where('program_id', $program->id)->where('status_sent_emel', 'sent')->count(),
|
||||||
|
'emails_failed' => (int) \DB::table('program_participants')->where('program_id', $program->id)->where('status_sent_emel', 'failed')->count(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('admin.programs.show', compact('program', 'stats'));
|
return view('admin.programs.show', compact('program', 'stats'));
|
||||||
@@ -122,13 +138,53 @@ class ProgramController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorize('delete', $program);
|
$this->authorize('delete', $program);
|
||||||
|
|
||||||
if ($program->attendances()->exists()) {
|
// Capture audit data before deletion
|
||||||
return back()->with('error', 'Program tidak boleh dipadam kerana sudah ada rekod kehadiran.');
|
$auditData = [
|
||||||
}
|
'program_title' => $program->title,
|
||||||
|
'start_date' => $program->start_date?->toDateString(),
|
||||||
|
'end_date' => $program->end_date?->toDateString(),
|
||||||
|
'program_created_at' => $program->created_at?->toDateTimeString(),
|
||||||
|
'deleted_by' => auth()->user()->name,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Collect file paths before transaction
|
||||||
|
$certFiles = $program->certificates()->pluck('file_path')->filter()->values()->all();
|
||||||
|
$templateFiles = $program->certificateTemplate ? [$program->certificateTemplate->image_path] : [];
|
||||||
|
$qrFiles = $program->qrCode ? [$program->qrCode->qr_image_path] : [];
|
||||||
|
|
||||||
$title = $program->title;
|
$title = $program->title;
|
||||||
AuditLogService::log('program.deleted', $program);
|
|
||||||
$program->delete();
|
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()
|
return redirect()
|
||||||
->route('admin.programs.index')
|
->route('admin.programs.index')
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Public;
|
namespace App\Http\Controllers\Public;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Jobs\SendCertificateEmailJob;
|
||||||
use App\Models\Certificate;
|
use App\Models\Certificate;
|
||||||
use App\Models\Participant;
|
use App\Models\Participant;
|
||||||
use App\Models\ProgramQrCode;
|
use App\Models\ProgramQrCode;
|
||||||
@@ -57,4 +58,35 @@ class AttendanceCheckController extends Controller
|
|||||||
return view('public.semak.result', compact('program', 'qrCode', 'participant', 'attendance', 'certificate'))
|
return view('public.semak.result', compact('program', 'qrCode', 'participant', 'attendance', 'certificate'))
|
||||||
->with('found', (bool) $attendance);
|
->with('found', (bool) $attendance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateEmail(string $qr_token, Request $request): View
|
||||||
|
{
|
||||||
|
$qrCode = ProgramQrCode::where('token', $qr_token)->where('is_active', true)->firstOrFail();
|
||||||
|
$program = $qrCode->program;
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'no_kp' => ['required', 'digits:12'],
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
], [
|
||||||
|
'email.required' => 'Sila masukkan alamat emel.',
|
||||||
|
'email.email' => 'Format emel tidak sah.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$participant = Participant::where('no_kp', $request->no_kp)->firstOrFail();
|
||||||
|
$participant->update(['email' => $request->email]);
|
||||||
|
|
||||||
|
$attendance = $participant->attendanceForProgram($program->id);
|
||||||
|
$certificate = Certificate::where('program_id', $program->id)
|
||||||
|
->where('participant_id', $participant->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// Masukkan queue hantar e-sijil jika sijil sudah dijana dan belum dihantar
|
||||||
|
if ($certificate && $certificate->status === 'generated' && ! $certificate->emailed_at) {
|
||||||
|
SendCertificateEmailJob::dispatchForCert($certificate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('public.semak.result', compact('program', 'qrCode', 'participant', 'attendance', 'certificate'))
|
||||||
|
->with('found', true)
|
||||||
|
->with('email_updated', true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -50,8 +50,11 @@ class CheckinController extends Controller
|
|||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'no_kp' => ['required', 'string', 'max:20'],
|
'no_kp' => ['required', 'string', 'max:20'],
|
||||||
|
'email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:20'],
|
||||||
], [
|
], [
|
||||||
'no_kp.required' => 'Sila masukkan No. Kad Pengenalan anda.',
|
'no_kp.required' => 'Sila masukkan No. Kad Pengenalan anda.',
|
||||||
|
'email.email' => 'Format emel tidak sah.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$result = $this->attendanceService->staffCheckin($program, $request->no_kp, $request);
|
$result = $this->attendanceService->staffCheckin($program, $request->no_kp, $request);
|
||||||
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 = [
|
protected $fillable = [
|
||||||
'program_id', 'participant_id',
|
'program_id', 'participant_id',
|
||||||
'registration_source', 'is_pre_registered', 'pre_registered_session',
|
'registration_source', 'is_pre_registered', 'pre_registered_session',
|
||||||
'status', 'registered_at',
|
'status', 'status_sent_emel', 'registered_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -40,6 +40,15 @@ class AttendanceService
|
|||||||
$attendance = DB::transaction(function () use ($program, $participant, $pp, $request) {
|
$attendance = DB::transaction(function () use ($program, $participant, $pp, $request) {
|
||||||
$session = $pp->pre_registered_session ?? $program->default_staff_session ?? 'full_day';
|
$session = $pp->pre_registered_session ?? $program->default_staff_session ?? 'full_day';
|
||||||
|
|
||||||
|
// Kemaskini emel/telefon peserta jika diisi semasa check-in
|
||||||
|
$contactUpdate = array_filter([
|
||||||
|
'email' => $request->filled('email') ? $request->input('email') : null,
|
||||||
|
'phone' => $request->filled('phone') ? $request->input('phone') : null,
|
||||||
|
]);
|
||||||
|
if ($contactUpdate) {
|
||||||
|
$participant->update($contactUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
$pp->update(['status' => 'checked_in']);
|
$pp->update(['status' => 'checked_in']);
|
||||||
|
|
||||||
return Attendance::create([
|
return Attendance::create([
|
||||||
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,
|
'serve' => true,
|
||||||
'throw' => false,
|
'throw' => false,
|
||||||
'report' => false,
|
'report' => false,
|
||||||
|
'visibility' => 'public',
|
||||||
],
|
],
|
||||||
|
|
||||||
'public' => [
|
'public' => [
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user