This commit is contained in:
Saufi
2026-06-24 18:30:00 +08:00
commit c0c3d7c09c
122 changed files with 16321 additions and 0 deletions

10
.claude/settings.json Normal file
View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(php -v)",
"Bash(composer -V)",
"Bash(npm -v)",
"Bash(mysql --version)"
]
}
}

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
# Kurangkan saiz build context (imej hanya perlukan docker/entrypoint.sh)
.git
.gitignore
.github
node_modules
vendor
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*
.env
.env.*
!.env.docker.example
tests
*.md

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4

46
.env.docker.example Normal file
View File

@@ -0,0 +1,46 @@
# ─────────────────────────────────────────────────────────────────────────────
# KOIPB MAT — Templat .env untuk Docker (dev & Ubuntu server)
#
# cp .env.docker.example .env
# # kemudian tukar SEMUA password di bawah
#
# Fail .env ini dibaca oleh Laravel (env_file) DAN oleh Docker Compose
# untuk gantian ${DB_*} pada service mysql.
# ─────────────────────────────────────────────────────────────────────────────
APP_NAME="KOIPB MAT"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
# Port nginx pada host (dev biasanya 8000; prod 80)
APP_PORT=80
APP_LOCALE=ms
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=ms_MY
# ── Database (DB_HOST mesti "mysql" = nama service Docker) ────────────────────
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=koipb_mat
DB_USERNAME=koipb
DB_PASSWORD=tukar_password_ini
# Digunakan hanya untuk root MySQL dalam container (gantian ${DB_ROOT_PASSWORD})
DB_ROOT_PASSWORD=tukar_root_password_ini
# Seed data demo (200 anggota + 21 hadiah) pada deploy pertama? true/false
SEED_DEMO=true
# ── Sesi / cache / queue guna database (tiada Redis diperlukan) ───────────────
SESSION_DRIVER=database
SESSION_LIFETIME=120
CACHE_STORE=database
QUEUE_CONNECTION=database
LOG_CHANNEL=stack
LOG_LEVEL=error
MAIL_MAILER=log

65
.env.example Normal file
View File

@@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
*.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/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true

178
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,178 @@
# DEPLOYMENT.md — Sistem MAT KOIPB (Docker · Ubuntu Server)
Panduan deploy aplikasi **Kehadiran & Cabutan Bertuah MAT KOIPB** menggunakan Docker pada server Ubuntu. Stack: **Nginx + PHP 8.4-FPM (Laravel) + MySQL 8** — semua dalam container, tiada pemasangan PHP/MySQL pada host.
> UI guna CDN + CSS statik (`public/css/koipb.css`) — **tiada langkah build Node/Vite**.
---
## Keperluan Server
| Komponen | Minimum | Disyorkan |
|---|---|---|
| OS | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS |
| CPU | 2 core | 4 core |
| RAM | 2 GB | 4 GB |
| Storage | 10 GB | 20 GB |
| Docker | 24.x | Latest stable |
| Docker Compose | 2.x | Latest stable |
Untuk 150200 peserta, server kecil sudah memadai.
---
## Struktur Fail Docker
```
/srv/koipb/
├── .env # disalin dari .env.docker.example (JANGAN commit)
├── .env.docker.example # templat
├── Dockerfile # imej PHP-FPM (Laravel)
├── docker-compose.yml # dev (nginx + app + mysql)
├── docker-compose.prod.yml # override production
├── docker/
│ ├── entrypoint.sh # wait-db, composer, key:generate, migrate, seed, cache
│ ├── nginx/default.conf
│ └── php/{php.ini, php-dev.ini, php-prod.ini}
└── ... (kod Laravel)
```
---
## Langkah Deployment Pertama Kali
### 1. Persediaan server
```bash
sudo apt update && sudo apt upgrade -y
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker
docker --version && docker compose version
```
### 2. Dapatkan kod
```bash
sudo mkdir -p /srv/koipb && sudo chown $USER:$USER /srv/koipb
cd /srv/koipb
git clone <repo-url> .
```
### 3. Konfigurasi environment
```bash
cp .env.docker.example .env
nano .env
```
Wajib tukar:
| Pemboleh ubah | Nota |
|---|---|
| `APP_KEY` | Biar kosong — entrypoint jana automatik kali pertama |
| `APP_URL` | cth `https://mat.koipb.gov.my` |
| `APP_PORT` | `80` (atau di belakang reverse proxy) |
| `DB_PASSWORD` | password user `koipb` |
| `DB_ROOT_PASSWORD` | password root MySQL container |
| `SEED_DEMO` | `true` = isi 200 anggota + 21 hadiah contoh; `false` = data kosong (import sendiri) |
> `DB_HOST` mesti kekal `mysql` (nama service container).
### 4. Logo
```bash
# Letak logo sebenar (kalau belum):
cp /path/ke/logo.jpg public/images/logo-koipb.jpg
```
### 5. Bina & jalankan (production)
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
Entrypoint akan automatik: tunggu MySQL → composer install → `key:generate``migrate --force` → seed peranan/pengguna (+ demo jika `SEED_DEMO=true`) → cache config/route/view.
Semak:
```bash
docker compose ps
docker compose logs -f app # tunggu "✅ Aplikasi bersedia."
```
Buka `http://<IP-server>` (atau `APP_URL`).
---
## Login Default
Kata laluan semua: **`password`** — **WAJIB tukar selepas log masuk pertama** (atau ubah dalam `RoleUserSeeder` sebelum deploy).
| Peranan | E-mel |
|---|---|
| Admin | `admin@koipb.test` |
| Petugas Kaunter | `kaunter@koipb.test` |
| Petugas Cabutan | `cabutan@koipb.test` |
---
## Operasi Harian
```bash
# Status / log
docker compose ps
docker compose logs -f app
# Masuk shell artisan
docker compose exec app php artisan <command>
# Backup database (penting sebelum & selepas event!)
docker compose exec mysql sh -c 'mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" koipb_mat' > backup-$(date +%F-%H%M).sql
# Restore
cat backup.sql | docker compose exec -T mysql sh -c 'mysql -u root -p"$MYSQL_ROOT_PASSWORD" koipb_mat'
# Henti / mula semula
docker compose -f docker-compose.yml -f docker-compose.prod.yml down
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
> Data MySQL kekal dalam named volume **`koipb_db_data`** walaupun container dipadam. `docker compose down` (tanpa `-v`) **tidak** memadam data. **Jangan** guna `down -v` semasa event.
---
## Kemaskini Versi (Redeploy)
```bash
cd /srv/koipb
git pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
# Entrypoint akan migrate + cache semula automatik.
```
---
## Reverse Proxy + HTTPS (pilihan)
Jika ada Nginx/Caddy di host atau sijil SSL, set `APP_PORT` kepada port dalaman (cth `8080`) dan proksi ke port itu. Contoh blok Nginx host:
```nginx
server {
server_name mat.koipb.gov.my;
location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; }
}
```
Kemudian pasang sijil melalui `certbot --nginx`.
---
## Penyelesaian Masalah
| Masalah | Tindakan |
|---|---|
| `app` asyik restart | `docker compose logs app` — biasanya MySQL belum sedia atau `.env` salah |
| `419 Page Expired` / log masuk gagal | `APP_KEY` kosong — `docker compose exec app php artisan key:generate --force` lalu `docker compose restart app` |
| 502 Bad Gateway | container `app` belum naik / belum siap `composer install`; tunggu log "Aplikasi bersedia" |
| Tukar `.env` tak berkesan | `docker compose exec app php artisan config:clear` dan restart |
| Reset data untuk event baharu | Log masuk admin → **Tetapan & Reset**, atau `docker compose exec app php artisan migrate:fresh --seed --force` |

60
Dockerfile Normal file
View File

@@ -0,0 +1,60 @@
###############################################################################
# KOIPB MAT — PHP-FPM Runtime Image
# Sistem Kehadiran & Cabutan Bertuah Mesyuarat Agung Tahunan KOIPB
# PHP 8.4 + extension Laravel (pdo_mysql, gd, zip, intl, opcache, ...)
###############################################################################
FROM php:8.4-fpm
LABEL org.opencontainers.image.title="KOIPB MAT" \
org.opencontainers.image.description="Sistem Kehadiran & Cabutan Bertuah MAT KOIPB"
# ── System libraries ──────────────────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
zip \
unzip \
# zip extension (openspout XLSX = zip-based)
libzip-dev \
# GD: PNG, JPEG, FreeType
libpng-dev \
libjpeg62-turbo-dev \
libfreetype6-dev \
# mbstring
libonig-dev \
# xml / intl
libxml2-dev \
libicu-dev \
# mysql client (wait-for-db dalam entrypoint)
default-mysql-client \
&& rm -rf /var/lib/apt/lists/*
# ── GD ────────────────────────────────────────────────────────────────────────
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" gd
# ── Core PHP extensions ───────────────────────────────────────────────────────
RUN docker-php-ext-install -j"$(nproc)" \
pdo_mysql \
mbstring \
exif \
pcntl \
bcmath \
zip \
intl \
opcache
# ── Composer ──────────────────────────────────────────────────────────────────
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# ── Working directory ─────────────────────────────────────────────────────────
WORKDIR /var/www/html
# ── Entrypoint ────────────────────────────────────────────────────────────────
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 9000
ENTRYPOINT ["entrypoint.sh"]
CMD ["php-fpm"]

143
README.md Normal file
View File

@@ -0,0 +1,143 @@
# Sistem Kehadiran & Cabutan Bertuah MAT — KOIPB
Aplikasi web **Laravel 12 + MySQL** untuk Mesyuarat Agung Tahunan (MAT) Koperasi Iskandar Puteri Berhad (KOIPB): rekod kehadiran anggota di kaunter dan jalankan cabutan bertuah (wheel of fortune) di skrin projektor.
Jangkaan kehadiran: 150200 orang.
---
## Teknologi
| Lapisan | Pilihan | Sebab |
|---|---|---|
| Framework | Laravel 12 (PHP 8.5) | Stabil, lengkap |
| Pangkalan data | MySQL 8 | Seperti diminta |
| UI | Blade + Bootstrap 5 (CDN) | Praktikal, responsive, tiada langkah build |
| Interaksi | jQuery + SweetAlert2 | Live search, alert kemas |
| Roda + confetti | Canvas asli + `canvas-confetti` | Animasi smooth, projector-friendly |
| Peranan/akses | `spatie/laravel-permission` | Role-based standard industri |
| Import/Export | `openspout/openspout` (CSV/XLSX) | Serasi PHP 8.5 (maatwebsite/excel TIDAK serasi PHP 8.5) |
| PDF laporan | `barryvdh/laravel-dompdf` | Eksport PDF mudah |
> **Nota penting:** `maatwebsite/excel` tidak boleh dipasang pada PHP 8.5 (bergantung pada phpspreadsheet 1.x yang dihadkan kepada PHP < 8.5). Sebab itu sistem guna **openspout** terus untuk baca/tulis CSV & XLSX.
---
## Setup Local
Prasyarat: PHP 8.5, Composer, MySQL berjalan.
```bash
# 1. Pasang dependency
composer install
# 2. Fail .env sudah disediakan. Pastikan tetapan DB betul:
# DB_DATABASE=koipb_mat DB_USERNAME=root DB_PASSWORD=1234
# Cipta database jika belum ada:
# CREATE DATABASE koipb_mat CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
# 3. Jana app key (jika .env belum ada APP_KEY)
php artisan key:generate
# 4. Migrate + seed (200 anggota, 21 item hadiah, 3 user)
php artisan migrate:fresh --seed
# 5. Jalankan
php artisan serve
# Buka http://127.0.0.1:8000
```
**Logo:** letakkan logo sebenar di `public/images/logo-koipb.jpg` (kini ada placeholder).
---
## Login Default
Semua kata laluan: **`password`**
| Peranan | E-mel | Akses |
|---|---|---|
| Admin | `admin@koipb.test` | Semua modul |
| Petugas Kaunter | `kaunter@koipb.test` | Kaunter kehadiran + senarai kehadiran |
| Petugas Cabutan / Urusetia | `cabutan@koipb.test` | Skrin cabutan + laporan |
---
## Route Penting
| URL | Modul | Akses |
|---|---|---|
| `/login` | Log masuk | Semua |
| `/dashboard` | Statistik event | Semua login |
| `/kaunter` | Kaunter kehadiran (live search) | Admin, Kaunter |
| `/kehadiran` | Senarai kehadiran + tapis + export | Admin, Kaunter |
| `/cabutan` | **Skrin cabutan (roda)** — projector | Admin, Cabutan |
| `/laporan` | Laporan + export Excel/PDF | Admin, Cabutan |
| `/admin/anggota` | Urus + import anggota | Admin |
| `/admin/hadiah` | Urus + import hadiah | Admin |
| `/admin/tetapan` | Tetapan sesi + reset event | Admin |
| `/admin/audit` | Audit trail | Admin |
Endpoint AJAX cabutan: `POST /cabutan/spin`, `/cabutan/confirm`, `/cabutan/batal`; `GET /cabutan/pool`.
---
## Cara Import CSV / XLSX
Admin → **Anggota / Hadiah → Import**. Format CSV atau XLSX. Baris pertama = nama lajur (header dinormalkan: huruf kecil + underscore). Templat contoh boleh dimuat turun dari skrin import (atau `public/samples/`).
**Anggota** — lajur: `no_anggota, no_pekerja, no_kp, nama, jabatan, bahagian, telefon, status_aktif`
- `nama` wajib; `no_kp` & `no_pekerja` unik; `status_aktif` default = aktif.
- Duplicate (dalam fail atau dalam DB) dilangkau — ringkasan **berjaya / duplicate / gagal** dipaparkan & disimpan dalam log import.
**Hadiah** — lajur: `kod_hadiah, nama_hadiah, kategori, nilai_anggaran, susunan_cabutan, kuantiti`
- Jika `kuantiti > 1`, sistem jana item berasingan (cth `Hamper #1``Hamper #5`).
---
## Cara Demo Cabutan (end-to-end)
1. Log masuk **admin** → import/seed anggota & hadiah (seeder sudah sediakan).
2. Log masuk **kaunter** (`/kaunter`) → cari nama/no KP/no pekerja → **Rekod Hadir**.
- Hanya anggota berdaftar boleh direkod; bukan anggota → mesej amaran.
- Double attendance dihalang (paras DB + UI).
3. Log masuk **cabutan** (`/cabutan`) di projektor:
- Pilih hadiah ikut susunan → **SPIN** (roda berpusing pada peserta hadir).
- Calon pemenang dipaparkan di panel kanan.
- **Confirm Pemenang** → panel hijau/emas + confetti + "TAHNIAH!".
- **Batal / Tiada Di Dewan** → panel merah; hadiah jadi *perlu cabut semula*.
- **Cabut Semula** → spin sekali lagi untuk hadiah sama (attempt bertambah).
4. **Laporan** (`/laporan`) → pemenang, hadiah belum dicabut, cabutan dibatalkan, kehadiran → export Excel/PDF.
---
## Rules / Jaminan Sistem
- Hanya anggota **hadir** masuk pool cabutan.
- Pemenang **disahkan** dikeluarkan dari pool — tidak boleh menang lagi.
- Cabutan **dibatalkan** boleh dicabut semula; setting default kekalkan peserta batal dalam pool.
- **Race condition / double-confirm** dihalang di paras DB melalui unique index (`confirmed_member_id`, `confirmed_prize_id`) + transaction `lockForUpdate`.
- **Double attendance** dihalang oleh unique constraint `attendance_records.member_id`.
- No KP **dimask** di UI biasa (cth `850101-01-****`).
- Semua tindakan penting (login, hadir, spin, confirm, batal, import, reset) direkod dalam **audit trail**.
---
## Skema Database
`users`, `roles`/`permissions` (spatie), `members`, `attendance_records`, `prizes`,
`draw_sessions`, `draw_results`, `import_logs`, `audit_logs`.
Index utama: `members.no_kp`, `members.no_pekerja`, `members.nama`,
`attendance_records.member_id` (unique), `prizes.draw_order`,
`draw_results.(prize_id|member_id|status)`, unique `confirmed_member_id` & `confirmed_prize_id`.
---
## Ujian
```bash
php artisan test
```
Liputan: kehadiran tidak duplicate, hanya hadir boleh masuk cabutan, pemenang disahkan tidak boleh menang lagi (paras servis + DB), cabutan batal boleh redraw, akses ikut peranan, import duplicate/kuantiti. **(18 ujian, semua lulus.)**

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AuditController extends Controller
{
public function index(Request $request): View
{
$action = $request->query('action');
$logs = AuditLog::with('user')
->when($action, fn ($q) => $q->where('action', $action))
->latest()
->paginate(40)
->withQueryString();
$actions = AuditLog::query()->distinct()->orderBy('action')->pluck('action');
return view('admin.audit.index', compact('logs', 'actions', 'action'));
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\ImportLog;
use App\Models\Member;
use App\Services\MemberImportService;
use App\Support\Spreadsheet;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MemberController extends Controller
{
public function index(Request $request): View
{
$q = $request->query('q');
$members = Member::query()
->when($q, fn ($query) => $query->search($q))
->withCount(['drawResults as confirmed_wins' => fn ($query) => $query->where('status', \App\Models\DrawResult::STATUS_CONFIRMED)])
->with('attendance')
->orderBy('nama')
->paginate(25)
->withQueryString();
$importLogs = ImportLog::where('type', 'members')->with('importedBy')->latest()->take(5)->get();
return view('admin.members.index', compact('members', 'q', 'importLogs'));
}
public function edit(Member $member): View
{
return view('admin.members.edit', compact('member'));
}
public function update(Request $request, Member $member): RedirectResponse
{
$data = $request->validate([
'no_anggota' => ['nullable', 'string', 'max:255'],
'no_pekerja' => ['nullable', 'string', 'max:255', "unique:members,no_pekerja,{$member->id}"],
'no_kp' => ['nullable', 'string', 'max:255', "unique:members,no_kp,{$member->id}"],
'nama' => ['required', 'string', 'max:255'],
'jabatan' => ['nullable', 'string', 'max:255'],
'bahagian' => ['nullable', 'string', 'max:255'],
'telefon' => ['nullable', 'string', 'max:255'],
'status_aktif' => ['required', 'boolean'],
]);
$member->update($data);
AuditLog::record('member.update', "Kemaskini anggota: {$member->nama}", $member);
return redirect()->route('admin.members.index')->with('success', 'Maklumat anggota dikemaskini.');
}
public function destroy(Member $member): RedirectResponse
{
$nama = $member->nama;
$member->delete();
AuditLog::record('member.delete', "Padam anggota: {$nama}");
return redirect()->route('admin.members.index')->with('success', "Anggota '{$nama}' dipadam.");
}
public function showImport(): View
{
$logs = ImportLog::where('type', 'members')->with('importedBy')->latest()->take(15)->get();
return view('admin.members.import', compact('logs'));
}
public function import(Request $request, MemberImportService $service): RedirectResponse
{
$request->validate([
'file' => ['required', 'file', 'mimes:csv,txt,xlsx,xls', 'max:10240'],
]);
$file = $request->file('file');
$rows = Spreadsheet::read($file->getRealPath(), $file->getClientOriginalExtension());
$log = $service->import($rows, $file->getClientOriginalName(), $request->user()->id);
AuditLog::record('member.import', "Import anggota: {$log->filename}", $log, [
'success' => $log->success_count,
'duplicate' => $log->duplicate_count,
'failed' => $log->failed_count,
]);
return redirect()->route('admin.members.import')->with('import_result', $log->id);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\ImportLog;
use App\Models\Prize;
use App\Services\PrizeImportService;
use App\Support\Spreadsheet;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class PrizeController extends Controller
{
public function index(): View
{
$prizes = Prize::query()->ordered()->with('drawResults.member')->paginate(30);
$importLogs = ImportLog::where('type', 'prizes')->with('importedBy')->latest()->take(5)->get();
return view('admin.prizes.index', compact('prizes', 'importLogs'));
}
public function edit(Prize $prize): View
{
return view('admin.prizes.edit', compact('prize'));
}
public function update(Request $request, Prize $prize): RedirectResponse
{
$data = $request->validate([
'kod_hadiah' => ['nullable', 'string', 'max:255'],
'nama_hadiah' => ['required', 'string', 'max:255'],
'kategori' => ['nullable', 'string', 'max:255'],
'nilai_anggaran' => ['nullable', 'numeric', 'min:0'],
'draw_order' => ['required', 'integer', 'min:0'],
]);
$prize->update($data);
AuditLog::record('prize.update', "Kemaskini hadiah: {$prize->nama_hadiah}", $prize);
return redirect()->route('admin.prizes.index')->with('success', 'Maklumat hadiah dikemaskini.');
}
public function destroy(Prize $prize): RedirectResponse
{
if ($prize->status === Prize::STATUS_DISAHKAN) {
return back()->with('error', 'Hadiah yang sudah ada pemenang disahkan tidak boleh dipadam.');
}
$nama = $prize->nama_hadiah;
$prize->delete();
AuditLog::record('prize.delete', "Padam hadiah: {$nama}");
return redirect()->route('admin.prizes.index')->with('success', "Hadiah '{$nama}' dipadam.");
}
public function showImport(): View
{
$logs = ImportLog::where('type', 'prizes')->with('importedBy')->latest()->take(15)->get();
return view('admin.prizes.import', compact('logs'));
}
public function import(Request $request, PrizeImportService $service): RedirectResponse
{
$request->validate([
'file' => ['required', 'file', 'mimes:csv,txt,xlsx,xls', 'max:10240'],
]);
$file = $request->file('file');
$rows = Spreadsheet::read($file->getRealPath(), $file->getClientOriginalExtension());
$log = $service->import($rows, $file->getClientOriginalName(), $request->user()->id);
AuditLog::record('prize.import', "Import hadiah: {$log->filename}", $log, [
'success' => $log->success_count,
'failed' => $log->failed_count,
]);
return redirect()->route('admin.prizes.import')->with('import_result', $log->id);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AttendanceRecord;
use App\Models\AuditLog;
use App\Models\DrawResult;
use App\Models\DrawSession;
use App\Models\Prize;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class SettingController extends Controller
{
public function index(): View
{
$session = DrawSession::active();
return view('admin.settings.index', compact('session'));
}
public function updateSession(Request $request): RedirectResponse
{
$data = $request->validate([
'nama' => ['required', 'string', 'max:255'],
'return_cancelled_to_pool' => ['required', 'boolean'],
]);
$session = DrawSession::active();
$session->update($data);
AuditLog::record('setting.session', 'Kemaskini tetapan sesi cabutan', $session, $data);
return back()->with('success', 'Tetapan sesi dikemaskini.');
}
/** Reset data kehadiran & cabutan untuk event baru */
public function reset(Request $request): RedirectResponse
{
$request->validate([
'confirm' => ['required', 'in:RESET'],
'scope' => ['required', 'in:all,draw'],
]);
DB::transaction(function () use ($request) {
DrawResult::query()->delete();
Prize::query()->update(['status' => Prize::STATUS_BELUM, 'redraw_count' => 0]);
if ($request->scope === 'all') {
AttendanceRecord::query()->delete();
}
});
AuditLog::record('setting.reset', 'Reset data event', null, ['scope' => $request->scope]);
return back()->with('success', 'Data event berjaya direset.');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use App\Models\Member;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AttendanceController extends Controller
{
/** Senarai kehadiran (hadir / belum hadir) dengan carian & penapis */
public function index(Request $request): View
{
$status = $request->query('status', 'hadir'); // hadir | belum | semua
$jabatan = $request->query('jabatan');
$q = $request->query('q');
$members = Member::query()
->with('attendance.recordedBy')
->when($status === 'hadir', fn ($query) => $query->hadir())
->when($status === 'belum', fn ($query) => $query->belumHadir())
->when($jabatan, fn ($query) => $query->where('jabatan', $jabatan))
->when($q, fn ($query) => $query->search($q))
->orderBy('nama')
->paginate(30)
->withQueryString();
$jabatanList = Member::query()
->whereNotNull('jabatan')
->distinct()
->orderBy('jabatan')
->pluck('jabatan');
$counts = [
'hadir' => Member::hadir()->count(),
'belum' => Member::belumHadir()->count(),
'semua' => Member::count(),
];
return view('attendance.index', compact('members', 'jabatanList', 'counts', 'status', 'jabatan', 'q'));
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class LoginController extends Controller
{
public function show(): View
{
return view('auth.login');
}
public function login(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
return back()
->withInput($request->only('email'))
->withErrors(['email' => 'E-mel atau kata laluan tidak sah.']);
}
$request->session()->regenerate();
AuditLog::record('auth.login', 'Log masuk berjaya');
return redirect()->intended(route('dashboard'));
}
public function logout(Request $request): RedirectResponse
{
AuditLog::record('auth.logout', 'Log keluar');
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers;
use App\Models\AttendanceRecord;
use App\Models\AuditLog;
use App\Models\Member;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CounterController extends Controller
{
public function index(): View
{
return view('counter.index');
}
/** Live search (AJAX) — laju untuk ~200 peserta */
public function search(Request $request): JsonResponse
{
$term = trim((string) $request->query('q', ''));
if (mb_strlen($term) < 2) {
return response()->json(['data' => [], 'message' => 'Taip sekurang-kurangnya 2 aksara.']);
}
$members = Member::query()
->search($term)
->with('attendance')
->orderBy('nama')
->limit(25)
->get()
->map(fn (Member $m) => $this->present($m));
return response()->json([
'data' => $members,
'message' => $members->isEmpty()
? 'Rekod tidak dijumpai. Individu ini bukan anggota koperasi yang berdaftar.'
: null,
]);
}
/** Rekod kehadiran — elak double attendance */
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'member_id' => ['required', 'integer', 'exists:members,id'],
]);
$member = Member::with('attendance')->findOrFail($data['member_id']);
if ($member->attendance) {
return response()->json([
'status' => 'already',
'message' => 'Anggota ini sudah direkodkan hadir.',
'member' => $this->present($member),
], 200);
}
try {
$attendance = AttendanceRecord::create([
'member_id' => $member->id,
'attended_at' => now(),
'recorded_by' => $request->user()->id,
'ip_address' => $request->ip(),
'user_agent' => substr((string) $request->userAgent(), 0, 255),
]);
} catch (UniqueConstraintViolationException $e) {
// Race: dua kaunter rekod serentak
$member->load('attendance');
return response()->json([
'status' => 'already',
'message' => 'Anggota ini sudah direkodkan hadir.',
'member' => $this->present($member),
], 200);
}
AuditLog::record('attendance.record', "Rekod hadir: {$member->nama}", $attendance, [
'member_id' => $member->id,
]);
$member->load('attendance');
return response()->json([
'status' => 'ok',
'message' => "Kehadiran {$member->nama} berjaya direkodkan.",
'member' => $this->present($member),
]);
}
private function present(Member $m): array
{
return [
'id' => $m->id,
'nama' => $m->nama,
'no_pekerja' => $m->no_pekerja,
'no_anggota' => $m->no_anggota,
'masked_kp' => $m->maskedKp(),
'jabatan' => $m->jabatan,
'bahagian' => $m->bahagian,
'status_aktif' => (bool) $m->status_aktif,
'hadir' => (bool) $m->attendance,
'attended_at' => $m->attendance?->attended_at?->format('d/m/Y H:i:s'),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use App\Models\AttendanceRecord;
use App\Models\DrawResult;
use App\Models\Member;
use App\Models\Prize;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(): View
{
$totalMembers = Member::count();
$hadir = AttendanceRecord::count();
$stats = [
'total_members' => $totalMembers,
'hadir' => $hadir,
'belum_hadir' => max($totalMembers - $hadir, 0),
'total_prizes' => Prize::count(),
'prizes_disahkan' => Prize::where('status', Prize::STATUS_DISAHKAN)->count(),
'prizes_belum' => Prize::available()->count(),
'pemenang_disahkan' => DrawResult::where('status', DrawResult::STATUS_CONFIRMED)->count(),
'cabutan_dibatalkan' => DrawResult::where('status', DrawResult::STATUS_CANCELLED)->count(),
];
$recentWinners = DrawResult::with(['member', 'prize'])
->where('status', DrawResult::STATUS_CONFIRMED)
->latest('confirmed_at')
->take(8)
->get();
return view('dashboard', compact('stats', 'recentWinners'));
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers;
use App\Models\AttendanceRecord;
use App\Models\DrawResult;
use App\Models\DrawSession;
use App\Models\Member;
use App\Models\Prize;
use App\Services\DrawService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;use RuntimeException;
class DrawController extends Controller
{
public function __construct(private readonly DrawService $service)
{
}
public function index(): View
{
$session = DrawSession::active();
// Setiap item hadiah diberi Nombor Cabutan 1..N ikut susunan (ascending draw_order).
// Cabutan berjalan MENURUN: mula dari nombor terakhir, hadiah utama (#1) dicabut terakhir.
$prizes = Prize::query()->ordered()->get()->values()->map(function (Prize $p, int $i) {
return [
'id' => $p->id,
'seq' => $i + 1,
'nama_hadiah' => $p->nama_hadiah,
'draw_order' => $p->draw_order,
'kategori' => $p->kategori,
'status' => $p->status,
];
});
$totalPrizes = $prizes->count();
$attendance = AttendanceRecord::count();
// Nombor terakhir (nombor mula cabutan) = jumlah hadiah,
// tetapi jika kehadiran < jumlah hadiah, hadkan kepada jumlah kehadiran.
$startNumber = min($totalPrizes, $attendance);
$eligibleCount = $this->service->eligibleMembers($session)->count();
// Cabutan pending semasa (jika ada) untuk sambung semula
$pending = DrawResult::with(['member', 'prize'])
->where('status', DrawResult::STATUS_PENDING)
->latest('spun_at')
->first();
return view('draw.index', compact(
'session', 'prizes', 'totalPrizes', 'attendance', 'startNumber', 'eligibleCount', 'pending'
));
}
/** Senarai pool semasa untuk render roda */
public function pool(): JsonResponse
{
$session = DrawSession::active();
return response()->json([
'pool' => $this->poolPayload($session),
]);
}
public function spin(Request $request, DrawService $service): JsonResponse
{
$data = $request->validate([
'prize_id' => ['required', 'integer', 'exists:prizes,id'],
]);
$session = DrawSession::active();
$prize = Prize::findOrFail($data['prize_id']);
try {
$result = $service->spin($prize, $session, $request->user());
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
// Pool selepas spin (winner mesti ada dalamnya)
$pool = $this->poolPayload($session);
$winnerIndex = collect($pool)->search(fn ($p) => $p['id'] === $result->member_id);
return response()->json([
'result_id' => $result->id,
'winner_index' => $winnerIndex === false ? 0 : $winnerIndex,
'pool' => $pool,
'winner' => $this->memberPayload($result->member),
'prize' => $this->prizePayload($result->prize),
'attempt' => $result->attempt_number,
]);
}
public function confirm(Request $request, DrawService $service): JsonResponse
{
$data = $request->validate(['result_id' => ['required', 'integer', 'exists:draw_results,id']]);
$result = DrawResult::findOrFail($data['result_id']);
try {
$result = $service->confirm($result, $request->user());
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json([
'status' => 'confirmed',
'message' => 'TAHNIAH! Pemenang telah disahkan.',
'winner' => $this->memberPayload($result->member),
'prize' => $this->prizePayload($result->prize),
]);
}
public function cancel(Request $request, DrawService $service): JsonResponse
{
$data = $request->validate([
'result_id' => ['required', 'integer', 'exists:draw_results,id'],
'sebab' => ['nullable', 'string', 'max:255'],
]);
$result = DrawResult::findOrFail($data['result_id']);
try {
$result = $service->cancel($result, $request->user(), $data['sebab'] ?? null);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json([
'status' => 'cancelled',
'message' => 'Cabutan dibatalkan. Hadiah boleh dicabut semula.',
'prize' => $this->prizePayload($result->prize),
]);
}
private function poolPayload(DrawSession $session): array
{
return $this->service->eligibleMembers($session)
->map(fn (Member $m) => [
'id' => $m->id,
'label' => $m->no_pekerja ?: $m->no_anggota ?: $m->nama,
'nama' => $m->nama,
])
->values()
->all();
}
private function memberPayload(Member $m): array
{
return [
'id' => $m->id,
'nama' => $m->nama,
'no_pekerja' => $m->no_pekerja,
'no_anggota' => $m->no_anggota,
'jabatan' => $m->jabatan,
'bahagian' => $m->bahagian,
'masked_kp' => $m->maskedKp(),
];
}
private function prizePayload(Prize $p): array
{
return [
'id' => $p->id,
'nama_hadiah' => $p->nama_hadiah,
'draw_order' => $p->draw_order,
'kategori' => $p->kategori,
'status' => $p->status,
'status_label' => $p->statusLabel(),
];
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers;
use App\Models\DrawResult;
use App\Models\Member;
use App\Models\Prize;
use App\Support\Spreadsheet;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;
class ReportController extends Controller
{
public function index(): View
{
$winners = DrawResult::with(['member', 'prize', 'confirmedBy'])
->where('status', DrawResult::STATUS_CONFIRMED)
->get()
->sortBy(fn ($r) => $r->prize->draw_order)
->values();
$cancelled = DrawResult::with(['member', 'prize', 'cancelledBy'])
->where('status', DrawResult::STATUS_CANCELLED)
->latest('cancelled_at')
->get();
$undrawn = Prize::available()->ordered()->get();
return view('reports.index', compact('winners', 'cancelled', 'undrawn'));
}
public function export(Request $request, string $type): Response
{
$format = $request->query('format', 'xlsx'); // xlsx | pdf
return match ($type) {
'attendance' => $this->exportAttendance($format),
'winners' => $this->exportWinners($format),
'undrawn' => $this->exportUndrawn($format),
'cancelled' => $this->exportCancelled($format),
default => abort(404),
};
}
private function exportAttendance(string $format): Response
{
$members = Member::hadir()->with('attendance')->orderBy('nama')->get();
$headings = ['Bil', 'No Anggota', 'No Pekerja', 'Nama', 'Jabatan', 'Bahagian', 'Masa Hadir'];
$rows = $members->values()->map(fn ($m, $i) => [
$i + 1, $m->no_anggota, $m->no_pekerja, $m->nama, $m->jabatan, $m->bahagian,
$m->attendance?->attended_at?->format('d/m/Y H:i:s'),
]);
return $this->respond('senarai-kehadiran', 'Senarai Kehadiran MAT KOIPB', $headings, $rows, $format);
}
private function exportWinners(string $format): Response
{
$winners = DrawResult::with(['member', 'prize'])
->where('status', DrawResult::STATUS_CONFIRMED)
->get()->sortBy(fn ($r) => $r->prize->draw_order)->values();
$headings = ['Susunan', 'Hadiah', 'Nama Pemenang', 'No Pekerja', 'Jabatan', 'Masa Disahkan'];
$rows = $winners->map(fn ($r) => [
$r->prize->draw_order, $r->prize->nama_hadiah, $r->member->nama,
$r->member->no_pekerja, $r->member->jabatan,
$r->confirmed_at?->format('d/m/Y H:i:s'),
]);
return $this->respond('senarai-pemenang', 'Senarai Pemenang Cabutan Bertuah', $headings, $rows, $format);
}
private function exportUndrawn(string $format): Response
{
$prizes = Prize::available()->ordered()->get();
$headings = ['Susunan', 'Kod', 'Hadiah', 'Kategori', 'Status'];
$rows = $prizes->map(fn ($p) => [
$p->draw_order, $p->kod_hadiah, $p->nama_hadiah, $p->kategori, $p->statusLabel(),
]);
return $this->respond('hadiah-belum-dicabut', 'Hadiah Belum Dicabut', $headings, $rows, $format);
}
private function exportCancelled(string $format): Response
{
$cancelled = DrawResult::with(['member', 'prize'])
->where('status', DrawResult::STATUS_CANCELLED)
->latest('cancelled_at')->get();
$headings = ['Hadiah', 'Nama', 'No Pekerja', 'Sebab Batal', 'Cubaan', 'Masa Batal'];
$rows = $cancelled->map(fn ($r) => [
$r->prize->nama_hadiah, $r->member->nama, $r->member->no_pekerja,
$r->sebab_batal, $r->attempt_number, $r->cancelled_at?->format('d/m/Y H:i:s'),
]);
return $this->respond('cabutan-dibatalkan', 'Cabutan Dibatalkan', $headings, $rows, $format);
}
private function respond(string $slug, string $title, array $headings, $rows, string $format): Response
{
if ($format === 'pdf') {
$pdf = Pdf::loadView('reports.pdf', [
'title' => $title,
'headings' => $headings,
'rows' => $rows,
])->setPaper('a4', 'landscape');
return $pdf->download($slug . '-' . date('Ymd-His') . '.pdf');
}
return Spreadsheet::download(
$slug . '-' . date('Ymd-His') . '.xlsx',
$headings,
$rows->map(fn ($r) => array_values((array) $r))->all(),
);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AttendanceRecord extends Model
{
protected $fillable = [
'member_id',
'attended_at',
'recorded_by',
'ip_address',
'user_agent',
];
protected function casts(): array
{
return [
'attended_at' => 'datetime',
];
}
public function member(): BelongsTo
{
return $this->belongsTo(Member::class);
}
public function recordedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'recorded_by');
}
}

52
app/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Facades\Request;
class AuditLog extends Model
{
protected $fillable = [
'user_id',
'action',
'description',
'subject_type',
'subject_id',
'ip_address',
'meta',
];
protected function casts(): array
{
return [
'meta' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subject(): MorphTo
{
return $this->morphTo();
}
/** Helper ringkas untuk rekod audit trail tindakan penting */
public static function record(string $action, ?string $description = null, ?Model $subject = null, array $meta = []): self
{
return static::create([
'user_id' => auth()->id(),
'action' => $action,
'description' => $description,
'subject_type' => $subject ? $subject::class : null,
'subject_id' => $subject?->getKey(),
'ip_address' => Request::ip(),
'meta' => $meta ?: null,
]);
}
}

83
app/Models/DrawResult.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DrawResult extends Model
{
public const STATUS_PENDING = 'pending_confirmation';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_CANCELLED = 'cancelled_absent';
public const STATUS_REDRAWN = 'redrawn';
public const STATUS_LABELS = [
self::STATUS_PENDING => 'Menunggu Pengesahan',
self::STATUS_CONFIRMED => 'Disahkan',
self::STATUS_CANCELLED => 'Dibatalkan (Tiada Di Dewan)',
self::STATUS_REDRAWN => 'Telah Dicabut Semula',
];
protected $fillable = [
'draw_session_id',
'prize_id',
'member_id',
'status',
'attempt_number',
'spun_at',
'confirmed_at',
'cancelled_at',
'confirmed_by',
'cancelled_by',
'sebab_batal',
'confirmed_member_id',
'confirmed_prize_id',
];
protected function casts(): array
{
return [
'spun_at' => 'datetime',
'confirmed_at' => 'datetime',
'cancelled_at' => 'datetime',
'attempt_number' => 'integer',
];
}
public function drawSession(): BelongsTo
{
return $this->belongsTo(DrawSession::class);
}
public function prize(): BelongsTo
{
return $this->belongsTo(Prize::class);
}
public function member(): BelongsTo
{
return $this->belongsTo(Member::class);
}
public function confirmedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'confirmed_by');
}
public function cancelledBy(): BelongsTo
{
return $this->belongsTo(User::class, 'cancelled_by');
}
public function statusLabel(): string
{
return self::STATUS_LABELS[$this->status] ?? $this->status;
}
public function scopeConfirmed(Builder $query): Builder
{
return $query->where('status', self::STATUS_CONFIRMED);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class DrawSession extends Model
{
protected $attributes = [
'is_active' => true,
'return_cancelled_to_pool' => true,
];
protected $fillable = [
'nama',
'is_active',
'return_cancelled_to_pool',
'started_by',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'return_cancelled_to_pool' => 'boolean',
];
}
public function results(): HasMany
{
return $this->hasMany(DrawResult::class);
}
public function startedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'started_by');
}
public static function active(): self
{
return static::firstOrCreate(
['is_active' => true],
['nama' => 'Mesyuarat Agung Tahunan KOIPB']
);
}
}

32
app/Models/ImportLog.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ImportLog extends Model
{
protected $fillable = [
'type',
'filename',
'total_rows',
'success_count',
'duplicate_count',
'failed_count',
'errors',
'imported_by',
];
protected function casts(): array
{
return [
'errors' => 'array',
];
}
public function importedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'imported_by');
}
}

110
app/Models/Member.php Normal file
View File

@@ -0,0 +1,110 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Member extends Model
{
use HasFactory;
protected $fillable = [
'no_anggota',
'no_pekerja',
'no_kp',
'nama',
'jabatan',
'bahagian',
'telefon',
'status_aktif',
];
protected function casts(): array
{
return [
'status_aktif' => 'boolean',
];
}
public function attendance(): HasOne
{
return $this->hasOne(AttendanceRecord::class);
}
public function drawResults(): HasMany
{
return $this->hasMany(DrawResult::class);
}
public function hasAttended(): bool
{
return $this->attendance()->exists();
}
/** Adakah anggota ini sudah menang & disahkan? */
public function hasConfirmedWin(): bool
{
return $this->drawResults()->where('status', DrawResult::STATUS_CONFIRMED)->exists();
}
/** No KP bertopeng untuk paparan biasa: 850101-01-**** */
public function maskedKp(): ?string
{
if (! $this->no_kp) {
return null;
}
$kp = $this->no_kp;
// Format standard 12 digit (boleh ada sempang)
$digits = preg_replace('/\D/', '', $kp);
if (strlen($digits) === 12) {
return substr($digits, 0, 6) . '-' . substr($digits, 6, 2) . '-****';
}
// Fallback: tunjukkan 4 aksara pertama, topeng selebihnya
$visible = substr($kp, 0, max(strlen($kp) - 4, 0));
return $visible . str_repeat('*', min(4, strlen($kp)));
}
public function scopeAktif(Builder $query): Builder
{
return $query->where('status_aktif', true);
}
public function scopeHadir(Builder $query): Builder
{
return $query->whereHas('attendance');
}
public function scopeBelumHadir(Builder $query): Builder
{
return $query->whereDoesntHave('attendance');
}
public function scopeSearch(Builder $query, ?string $term): Builder
{
$term = trim((string) $term);
if ($term === '') {
return $query;
}
$like = '%' . $term . '%';
$digits = preg_replace('/\D/', '', $term);
return $query->where(function (Builder $q) use ($like, $term, $digits) {
$q->where('nama', 'like', $like)
->orWhere('no_pekerja', 'like', $like)
->orWhere('no_anggota', 'like', $like)
->orWhere('no_kp', 'like', $like);
if ($digits !== '') {
$q->orWhere('no_kp', 'like', '%' . $digits . '%')
->orWhere('no_pekerja', 'like', '%' . $digits . '%');
}
});
}
}

91
app/Models/Prize.php Normal file
View File

@@ -0,0 +1,91 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Prize extends Model
{
use HasFactory;
public const STATUS_BELUM = 'belum_dicabut';
public const STATUS_SEDANG = 'sedang_dicabut';
public const STATUS_DISAHKAN = 'disahkan';
public const STATUS_REDRAW = 'redraw_required';
public const STATUS_LABELS = [
self::STATUS_BELUM => 'Belum Dicabut',
self::STATUS_SEDANG => 'Sedang Dicabut',
self::STATUS_DISAHKAN => 'Disahkan',
self::STATUS_REDRAW => 'Perlu Cabut Semula',
];
protected $attributes = [
'status' => self::STATUS_BELUM,
'item_index' => 1,
'draw_order' => 0,
'redraw_count' => 0,
];
protected $fillable = [
'kod_hadiah',
'nama_hadiah',
'kategori',
'nilai_anggaran',
'draw_order',
'item_index',
'batch_kod',
'status',
'redraw_count',
];
protected function casts(): array
{
return [
'nilai_anggaran' => 'decimal:2',
'draw_order' => 'integer',
'item_index' => 'integer',
'redraw_count' => 'integer',
];
}
public function drawResults(): HasMany
{
return $this->hasMany(DrawResult::class);
}
public function confirmedResult(): HasMany
{
return $this->hasMany(DrawResult::class)->where('status', DrawResult::STATUS_CONFIRMED);
}
public function statusLabel(): string
{
return self::STATUS_LABELS[$this->status] ?? $this->status;
}
/** Nama paparan termasuk nombor item bila kuantiti > 1 cth "Hamper #3" */
public function displayName(): string
{
return $this->nama_hadiah;
}
/** Hadiah yang masih boleh dicabut (belum ada pemenang disahkan) */
public function isAvailable(): bool
{
return in_array($this->status, [self::STATUS_BELUM, self::STATUS_REDRAW], true);
}
public function scopeAvailable(Builder $query): Builder
{
return $query->whereIn('status', [self::STATUS_BELUM, self::STATUS_REDRAW]);
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('draw_order')->orderBy('item_index')->orderBy('id');
}
}

38
app/Models/User.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasRoles;
public const ROLE_ADMIN = 'admin';
public const ROLE_KAUNTER = 'petugas_kaunter';
public const ROLE_CABUTAN = 'petugas_cabutan';
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\DrawResult;
use App\Models\DrawSession;
use App\Models\Member;
use App\Models\Prize;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class DrawService
{
/**
* Senarai anggota yang LAYAK masuk cabutan untuk sesi ini:
* - sudah hadir
* - belum menang & disahkan
* - (ikut setting) yang dibatalkan boleh kekal/dikeluarkan dari pool
*
* @return Collection<int, Member>
*/
public function eligibleMembers(DrawSession $session): Collection
{
$query = Member::query()
->hadir()
->whereDoesntHave('drawResults', function ($q) {
$q->where('status', DrawResult::STATUS_CONFIRMED);
});
if (! $session->return_cancelled_to_pool) {
// Setting: keluarkan terus peserta yang pernah dibatalkan
$query->whereDoesntHave('drawResults', function ($q) {
$q->where('status', DrawResult::STATUS_CANCELLED);
});
}
return $query->orderBy('id')->get();
}
/**
* Pusing roda: pilih seorang anggota layak secara rawak & cipta rekod
* pending_confirmation untuk hadiah yang dipilih.
*/
public function spin(Prize $prize, DrawSession $session, User $user): DrawResult
{
if (! $prize->isAvailable()) {
throw new RuntimeException('Hadiah ini sudah ada pemenang disahkan atau sedang diproses.');
}
$pool = $this->eligibleMembers($session);
if ($pool->isEmpty()) {
throw new RuntimeException('Tiada peserta layak yang masih ada dalam pool cabutan.');
}
return DB::transaction(function () use ($prize, $session, $user, $pool) {
$prize = Prize::whereKey($prize->getKey())->lockForUpdate()->firstOrFail();
if (! $prize->isAvailable()) {
throw new RuntimeException('Hadiah ini sudah ada pemenang disahkan.');
}
// Tandakan sebarang pending sedia ada untuk hadiah ini sebagai redrawn
DrawResult::where('prize_id', $prize->id)
->where('status', DrawResult::STATUS_PENDING)
->update([
'status' => DrawResult::STATUS_REDRAWN,
'updated_at' => now(),
]);
$member = $pool->random();
$attempt = DrawResult::where('prize_id', $prize->id)->count() + 1;
$result = DrawResult::create([
'draw_session_id' => $session->id,
'prize_id' => $prize->id,
'member_id' => $member->id,
'status' => DrawResult::STATUS_PENDING,
'attempt_number' => $attempt,
'spun_at' => now(),
]);
$prize->update(['status' => Prize::STATUS_SEDANG]);
AuditLog::record('draw.spin', "Spin hadiah '{$prize->nama_hadiah}' -> {$member->nama}", $result, [
'prize_id' => $prize->id,
'member_id' => $member->id,
'attempt' => $attempt,
]);
return $result->load('member', 'prize');
});
}
/**
* Sahkan pemenang. Dilindungi unique index DB supaya tiada
* double-confirm (race condition) walaupun dua operator tekan serentak.
*/
public function confirm(DrawResult $result, User $user): DrawResult
{
return DB::transaction(function () use ($result, $user) {
$result = DrawResult::whereKey($result->getKey())->lockForUpdate()->firstOrFail();
if ($result->status === DrawResult::STATUS_CONFIRMED) {
return $result; // idempotent
}
if ($result->status !== DrawResult::STATUS_PENDING) {
throw new RuntimeException('Cabutan ini tidak lagi menunggu pengesahan.');
}
$member = Member::findOrFail($result->member_id);
if ($member->hasConfirmedWin()) {
throw new RuntimeException("{$member->nama} sudah pun memenangi hadiah lain dan disahkan.");
}
try {
$result->update([
'status' => DrawResult::STATUS_CONFIRMED,
'confirmed_at' => now(),
'confirmed_by' => $user->id,
'confirmed_member_id' => $result->member_id,
'confirmed_prize_id' => $result->prize_id,
]);
} catch (UniqueConstraintViolationException|QueryException $e) {
// Unique index DB menghalang double winner / member menang dua kali
throw new RuntimeException('Pengesahan gagal: pemenang atau hadiah ini sudah disahkan oleh operator lain.');
}
Prize::whereKey($result->prize_id)->update(['status' => Prize::STATUS_DISAHKAN]);
AuditLog::record('draw.confirm', "Sahkan pemenang '{$member->nama}' untuk hadiah #{$result->prize_id}", $result, [
'prize_id' => $result->prize_id,
'member_id' => $result->member_id,
]);
return $result->fresh(['member', 'prize']);
});
}
/**
* Batalkan pemenang (tiada di dewan). Hadiah jadi 'redraw_required'
* dan boleh dicabut semula.
*/
public function cancel(DrawResult $result, User $user, ?string $reason = null): DrawResult
{
return DB::transaction(function () use ($result, $user, $reason) {
$result = DrawResult::whereKey($result->getKey())->lockForUpdate()->firstOrFail();
if ($result->status !== DrawResult::STATUS_PENDING) {
throw new RuntimeException('Hanya cabutan yang menunggu pengesahan boleh dibatalkan.');
}
$result->update([
'status' => DrawResult::STATUS_CANCELLED,
'cancelled_at' => now(),
'cancelled_by' => $user->id,
'sebab_batal' => $reason ?: 'Pemenang tiada di dewan',
]);
$prize = Prize::findOrFail($result->prize_id);
$prize->update([
'status' => Prize::STATUS_REDRAW,
'redraw_count' => $prize->redraw_count + 1,
]);
AuditLog::record('draw.cancel', "Batal cabutan hadiah #{$result->prize_id} (member #{$result->member_id})", $result, [
'prize_id' => $result->prize_id,
'member_id' => $result->member_id,
'sebab' => $result->sebab_batal,
]);
return $result->fresh(['member', 'prize']);
});
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Services;
use App\Models\ImportLog;
use App\Models\Member;
class MemberImportService
{
/**
* Import baris anggota. Tidak crash bila ada duplicate/ralat
* pulangkan ringkasan + simpan ImportLog.
*
* @param array<int, array<string, string>> $rows
*/
public function import(array $rows, string $filename, ?int $userId = null): ImportLog
{
$success = 0;
$duplicate = 0;
$failed = 0;
$errors = [];
// Set pra-muat untuk kesan duplicate dalam fail itu sendiri
$seenKp = [];
$seenPekerja = [];
foreach ($rows as $i => $row) {
$line = $i + 2; // baris fail (header = 1)
$nama = trim($row['nama'] ?? '');
$noKp = $this->clean($row['no_kp'] ?? '');
$noPekerja = $this->clean($row['no_pekerja'] ?? '');
$noAnggota = $this->clean($row['no_anggota'] ?? '');
if ($nama === '') {
$failed++;
$errors[] = "Baris {$line}: nama wajib diisi.";
continue;
}
// Duplicate dalam fail
if ($noKp && isset($seenKp[$noKp])) {
$duplicate++;
$errors[] = "Baris {$line}: no_kp '{$noKp}' duplicate dalam fail.";
continue;
}
if ($noPekerja && isset($seenPekerja[$noPekerja])) {
$duplicate++;
$errors[] = "Baris {$line}: no_pekerja '{$noPekerja}' duplicate dalam fail.";
continue;
}
// Duplicate dalam DB
$exists = Member::query()
->when($noKp, fn ($q) => $q->orWhere('no_kp', $noKp))
->when($noPekerja, fn ($q) => $q->orWhere('no_pekerja', $noPekerja))
->when($noKp || $noPekerja, fn ($q) => $q, fn ($q) => $q->whereRaw('1 = 0'))
->exists();
if ($exists) {
$duplicate++;
$errors[] = "Baris {$line}: anggota '{$nama}' sudah wujud (no_kp/no_pekerja sepadan).";
continue;
}
try {
Member::create([
'no_anggota' => $noAnggota ?: null,
'no_pekerja' => $noPekerja ?: null,
'no_kp' => $noKp ?: null,
'nama' => $nama,
'jabatan' => $this->clean($row['jabatan'] ?? '') ?: null,
'bahagian' => $this->clean($row['bahagian'] ?? '') ?: null,
'telefon' => $this->clean($row['telefon'] ?? '') ?: null,
'status_aktif' => $this->parseAktif($row['status_aktif'] ?? ''),
]);
if ($noKp) {
$seenKp[$noKp] = true;
}
if ($noPekerja) {
$seenPekerja[$noPekerja] = true;
}
$success++;
} catch (\Throwable $e) {
$failed++;
$errors[] = "Baris {$line}: {$e->getMessage()}";
}
}
return ImportLog::create([
'type' => 'members',
'filename' => $filename,
'total_rows' => count($rows),
'success_count' => $success,
'duplicate_count' => $duplicate,
'failed_count' => $failed,
'errors' => $errors ?: null,
'imported_by' => $userId,
]);
}
private function clean(string $value): string
{
return trim($value);
}
private function parseAktif(string $value): bool
{
$value = strtolower(trim($value));
if ($value === '') {
return true; // default aktif
}
return in_array($value, ['1', 'aktif', 'active', 'ya', 'yes', 'true', 'y'], true);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Services;
use App\Models\ImportLog;
use App\Models\Prize;
class PrizeImportService
{
/**
* Import baris hadiah. Jika kuantiti > 1, generate beberapa item berasingan
* (cth Hamper #1 .. Hamper #5).
*
* @param array<int, array<string, string>> $rows
*/
public function import(array $rows, string $filename, ?int $userId = null): ImportLog
{
$success = 0;
$duplicate = 0;
$failed = 0;
$errors = [];
foreach ($rows as $i => $row) {
$line = $i + 2;
$nama = trim($row['nama_hadiah'] ?? '');
if ($nama === '') {
$failed++;
$errors[] = "Baris {$line}: nama_hadiah wajib diisi.";
continue;
}
$kuantiti = max(1, (int) ($row['kuantiti'] ?? 1));
$drawOrder = (int) ($row['susunan_cabutan'] ?? ($row['draw_order'] ?? 0));
$kodHadiah = trim($row['kod_hadiah'] ?? '');
$batch = $kodHadiah !== '' ? $kodHadiah : 'B' . str_pad((string) ($i + 1), 4, '0', STR_PAD_LEFT);
try {
foreach ($this->expand($nama, $kuantiti) as $index => $itemName) {
Prize::create([
'kod_hadiah' => $kodHadiah ?: null,
'nama_hadiah' => $itemName,
'kategori' => trim($row['kategori'] ?? '') ?: null,
'nilai_anggaran' => $this->parseDecimal($row['nilai_anggaran'] ?? ''),
'draw_order' => $drawOrder,
'item_index' => $index,
'batch_kod' => $batch,
'status' => Prize::STATUS_BELUM,
]);
$success++;
}
} catch (\Throwable $e) {
$failed++;
$errors[] = "Baris {$line}: {$e->getMessage()}";
}
}
return ImportLog::create([
'type' => 'prizes',
'filename' => $filename,
'total_rows' => count($rows),
'success_count' => $success,
'duplicate_count' => $duplicate,
'failed_count' => $failed,
'errors' => $errors ?: null,
'imported_by' => $userId,
]);
}
/**
* @return array<int, string> diindeks dari 1
*/
public function expand(string $nama, int $kuantiti): array
{
if ($kuantiti <= 1) {
return [1 => $nama];
}
$items = [];
for ($n = 1; $n <= $kuantiti; $n++) {
$items[$n] = "{$nama} #{$n}";
}
return $items;
}
private function parseDecimal(string $value): ?float
{
$value = trim(str_replace([',', 'RM', 'rm'], '', $value));
return $value === '' ? null : (float) $value;
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Support;
use OpenSpout\Common\Entity\Row as SpoutRow;
use OpenSpout\Reader\CSV\Reader as CsvReader;
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
use OpenSpout\Writer\CSV\Writer as CsvWriter;
use OpenSpout\Writer\XLSX\Writer as XlsxWriter;
use Symfony\Component\HttpFoundation\StreamedResponse;
class Spreadsheet
{
/**
* Baca fail CSV/XLSX menjadi senarai baris bersekutu (keyed by header).
* Header dinormalkan: huruf kecil, ruang/sempang -> underscore.
*
* @return array<int, array<string, string>>
*/
public static function read(string $path, ?string $extension = null): array
{
$extension = strtolower($extension ?? pathinfo($path, PATHINFO_EXTENSION));
$reader = $extension === 'csv' ? new CsvReader() : new XlsxReader();
$reader->open($path);
$headers = [];
$rows = [];
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $index => $row) {
$cells = array_map(
static fn ($v) => is_string($v) ? trim($v) : (is_null($v) ? '' : (string) $v),
$row->toArray()
);
if ($index === 1) {
$headers = array_map(self::normalizeHeader(...), $cells);
continue;
}
// Langkau baris kosong sepenuhnya
if (count(array_filter($cells, static fn ($v) => $v !== '')) === 0) {
continue;
}
$assoc = [];
foreach ($headers as $i => $key) {
if ($key === '') {
continue;
}
$assoc[$key] = $cells[$i] ?? '';
}
$rows[] = $assoc;
}
break; // hanya sheet pertama
}
$reader->close();
return $rows;
}
private static function normalizeHeader(string $header): string
{
$header = strtolower(trim($header));
$header = preg_replace('/[\s\-]+/', '_', $header);
return preg_replace('/[^a-z0-9_]/', '', $header);
}
/**
* Hantar muat turun XLSX (atau CSV) terus ke pelayar.
*
* @param array<int, string> $headings
* @param iterable<int, array<int, scalar|null>> $rows
*/
public static function download(string $filename, array $headings, iterable $rows, string $format = 'xlsx'): StreamedResponse
{
return response()->streamDownload(function () use ($headings, $rows, $format) {
$writer = $format === 'csv' ? new CsvWriter() : new XlsxWriter();
$writer->openToFile('php://output');
$writer->addRow(SpoutRow::fromValues($headings));
foreach ($rows as $row) {
$writer->addRow(SpoutRow::fromValues($row));
}
$writer->close();
}, $filename, [
'Content-Type' => $format === 'csv'
? 'text/csv'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]);
}
}

18
artisan Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

27
bootstrap/app.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
]);
$middleware->redirectGuestsTo(fn () => route('login'));
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

7
bootstrap/providers.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];

89
composer.json Normal file
View File

@@ -0,0 +1,89 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^13.8",
"laravel/tinker": "^3.0",
"openspout/openspout": "^5.7",
"spatie/laravel-permission": "^8.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

9058
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

117
config/auth.php Normal file
View File

@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

136
config/cache.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

184
config/database.php Normal file
View File

@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

80
config/filesystems.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

132
config/logging.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];

219
config/permission.php Normal file
View File

@@ -0,0 +1,219 @@
<?php
use Spatie\Permission\DefaultTeamResolver;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
return [
'models' => [
/*
* When using the "HasPermissions" trait from this package, we need to know which
* Eloquent model should be used to retrieve your permissions. Of course, it
* is often just the "Permission" model but you may use whatever you like.
*
* The model you want to use as a Permission model needs to implement the
* `Spatie\Permission\Contracts\Permission` contract.
*/
'permission' => Permission::class,
/*
* When using the "HasRoles" trait from this package, we need to know which
* Eloquent model should be used to retrieve your roles. Of course, it
* is often just the "Role" model but you may use whatever you like.
*
* The model you want to use as a Role model needs to implement the
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => Role::class,
/*
* When using the "Teams" feature from this package, we need to know which
* Eloquent model should be used to retrieve your teams. Of course, it
* is often just the "Team" model but you may use whatever you like.
*/
'team' => null,
/*
* When using the "HasModels" trait and passing raw IDs to syncModels,
* attachModels, or detachModels, this model class will be used to
* resolve those IDs. If null, defaults to the guard's model.
*/
'default_model' => null,
],
'table_names' => [
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'roles' => 'roles',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your permissions. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'permissions' => 'permissions',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your models permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_permissions' => 'model_has_permissions',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your models roles. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_roles' => 'model_has_roles',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'role_has_permissions' => 'role_has_permissions',
],
'column_names' => [
/*
* Change this if you want to name the related pivots other than defaults
*/
'role_pivot_key' => null, // default 'role_id',
'permission_pivot_key' => null, // default 'permission_id',
/*
* Change this if you want to name the related model primary key other than
* `model_id`.
*
* For example, this would be nice if your primary keys are all UUIDs. In
* that case, name this `model_uuid`.
*/
'model_morph_key' => 'model_id',
/*
* Change this if you want to use the teams feature and your related model's
* foreign key is other than `team_id`.
*/
'team_foreign_key' => 'team_id',
],
/*
* When set to true, the method for checking permissions will be registered on the gate.
* Set this to false if you want to implement custom logic for checking permissions.
*/
'register_permission_check_method' => true,
/*
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
*/
'register_octane_reset_listener' => false,
/*
* Events will fire when a role or permission is assigned/unassigned:
* \Spatie\Permission\Events\RoleAttachedEvent
* \Spatie\Permission\Events\RoleDetachedEvent
* \Spatie\Permission\Events\PermissionAttachedEvent
* \Spatie\Permission\Events\PermissionDetachedEvent
*
* To enable, set to true, and then create listeners to watch these events.
*/
'events_enabled' => false,
/*
* Teams Feature.
* When set to true the package implements teams using the 'team_foreign_key'.
* If you want the migrations to register the 'team_foreign_key', you must
* set this to true before doing the migration.
* If you already did the migration then you must make a new migration to also
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
* (view the latest version of this package's migration file)
*/
'teams' => false,
/*
* The class to use to resolve the permissions team id
*/
'team_resolver' => DefaultTeamResolver::class,
/*
* Passport Client Credentials Grant
* When set to true the package will use Passports Client to check permissions
*/
'use_passport_client_credentials' => false,
/*
* When set to true, the required permission names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_permission_in_exception' => false,
/*
* When set to true, the required role names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_role_in_exception' => false,
/*
* By default wildcard permission lookups are disabled.
* See documentation to understand supported syntax.
*/
'enable_wildcard_permission' => false,
/*
* The class to use for interpreting wildcard permissions.
* If you need to modify delimiters, override the class and specify its name here.
*/
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
/* Cache-specific settings */
'cache' => [
/*
* By default all permissions are cached for 24 hours to speed up performance.
* When permissions or roles are updated the cache is flushed automatically.
*/
'expiration_time' => DateInterval::createFromDateString('24 hours'),
/*
* The cache key used to store all permissions.
*/
'key' => 'spatie.permission.cache',
/*
* You may optionally indicate a specific cache driver to use for permission and
* role caching using any of the `store` drivers listed in the cache.php config
* file. Using 'default' here means to use the `default` set in cache.php.
*/
'store' => 'default',
],
];

129
config/queue.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

38
config/services.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

233
config/session.php Normal file
View File

@@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,46 @@
<?php
namespace Database\Factories;
use App\Models\Member;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Member>
*/
class MemberFactory extends Factory
{
protected $model = Member::class;
public function definition(): array
{
$jabatan = $this->faker->randomElement([
'Khidmat Pengurusan', 'Kewangan', 'Perancangan', 'Kejuruteraan',
'Komuniti & Ekonomi', 'Teknologi Maklumat', 'Undang-undang', 'Audit Dalam',
'Landskap', 'Penguatkuasaan',
]);
$bahagian = $this->faker->randomElement([
'Unit A', 'Unit B', 'Unit C', 'Pentadbiran', 'Operasi', 'Sokongan',
]);
// No KP Malaysia 12 digit: YYMMDD-PB-###G
$yy = str_pad((string) $this->faker->numberBetween(60, 99), 2, '0', STR_PAD_LEFT);
$mm = str_pad((string) $this->faker->numberBetween(1, 12), 2, '0', STR_PAD_LEFT);
$dd = str_pad((string) $this->faker->numberBetween(1, 28), 2, '0', STR_PAD_LEFT);
$pb = str_pad((string) $this->faker->numberBetween(1, 14), 2, '0', STR_PAD_LEFT);
$serial = str_pad((string) $this->faker->numberBetween(0, 9999), 4, '0', STR_PAD_LEFT);
$noKp = "{$yy}{$mm}{$dd}-{$pb}-{$serial}";
return [
'no_anggota' => 'A' . str_pad((string) $this->faker->unique()->numberBetween(1, 99999), 5, '0', STR_PAD_LEFT),
'no_pekerja' => 'MBIP' . str_pad((string) $this->faker->unique()->numberBetween(1, 99999), 5, '0', STR_PAD_LEFT),
'no_kp' => $noKp,
'nama' => $this->faker->name(),
'jabatan' => $jabatan,
'bahagian' => $bahagian,
'telefon' => '01' . $this->faker->numberBetween(0, 9) . '-' . $this->faker->numerify('#######'),
'status_aktif' => $this->faker->boolean(95),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('members', function (Blueprint $table) {
$table->id();
$table->string('no_anggota')->nullable()->index();
$table->string('no_pekerja')->nullable()->unique();
$table->string('no_kp')->nullable()->unique();
$table->string('nama')->index();
$table->string('jabatan')->nullable();
$table->string('bahagian')->nullable();
$table->string('telefon')->nullable();
$table->boolean('status_aktif')->default(true);
$table->timestamps();
$table->index(['jabatan', 'bahagian']);
});
}
public function down(): void
{
Schema::dropIfExists('members');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('attendance_records', function (Blueprint $table) {
$table->id();
// Satu rekod kehadiran sahaja bagi setiap anggota (elak double attendance)
$table->foreignId('member_id')->unique()->constrained('members')->cascadeOnDelete();
$table->timestamp('attended_at')->useCurrent();
$table->foreignId('recorded_by')->nullable()->constrained('users')->nullOnDelete();
$table->string('ip_address', 45)->nullable();
$table->string('user_agent')->nullable();
$table->timestamps();
$table->index('attended_at');
});
}
public function down(): void
{
Schema::dropIfExists('attendance_records');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('prizes', function (Blueprint $table) {
$table->id();
$table->string('kod_hadiah')->nullable()->index();
$table->string('nama_hadiah');
$table->string('kategori')->nullable();
$table->decimal('nilai_anggaran', 12, 2)->nullable();
$table->unsignedInteger('draw_order')->default(0)->index();
$table->unsignedInteger('item_index')->default(1); // #1, #2 ... bagi kuantiti > 1
$table->string('batch_kod')->nullable()->index(); // kumpulkan item dari satu baris import
// belum_dicabut, sedang_dicabut, disahkan, redraw_required
$table->string('status')->default('belum_dicabut')->index();
$table->unsignedInteger('redraw_count')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('prizes');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('draw_sessions', function (Blueprint $table) {
$table->id();
$table->string('nama');
$table->boolean('is_active')->default(true);
// Setting: masukkan semula peserta yang dibatalkan ke pool? Default: ya
$table->boolean('return_cancelled_to_pool')->default(true);
$table->foreignId('started_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('draw_sessions');
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('draw_results', function (Blueprint $table) {
$table->id();
$table->foreignId('draw_session_id')->constrained('draw_sessions')->cascadeOnDelete();
$table->foreignId('prize_id')->constrained('prizes')->cascadeOnDelete();
$table->foreignId('member_id')->constrained('members')->cascadeOnDelete();
// pending_confirmation, confirmed, cancelled_absent, redrawn
$table->string('status')->default('pending_confirmation')->index();
$table->unsignedInteger('attempt_number')->default(1);
$table->timestamp('spun_at')->useCurrent();
$table->timestamp('confirmed_at')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->foreignId('confirmed_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('cancelled_by')->nullable()->constrained('users')->nullOnDelete();
$table->string('sebab_batal')->nullable();
// Diset oleh aplikasi (dalam transaction confirm) hanya bila status = confirmed,
// selainnya NULL. Unique index kuatkuasakan di peringkat DB:
// - seorang anggota hanya boleh ada SATU pengesahan (tak boleh menang lagi)
// - sesuatu hadiah hanya boleh ada SATU pemenang disahkan
$table->unsignedBigInteger('confirmed_member_id')->nullable();
$table->unsignedBigInteger('confirmed_prize_id')->nullable();
$table->timestamps();
$table->index(['prize_id', 'status']);
$table->index(['member_id', 'status']);
$table->unique('confirmed_member_id', 'uq_confirmed_member');
$table->unique('confirmed_prize_id', 'uq_confirmed_prize');
});
}
public function down(): void
{
Schema::dropIfExists('draw_results');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('import_logs', function (Blueprint $table) {
$table->id();
$table->string('type'); // members | prizes
$table->string('filename');
$table->unsignedInteger('total_rows')->default(0);
$table->unsignedInteger('success_count')->default(0);
$table->unsignedInteger('duplicate_count')->default(0);
$table->unsignedInteger('failed_count')->default(0);
$table->json('errors')->nullable();
$table->foreignId('imported_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('import_logs');
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('action')->index();
$table->string('description')->nullable();
$table->nullableMorphs('subject');
$table->string('ip_address', 45)->nullable();
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};

View File

@@ -0,0 +1,137 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id
$table->string('name');
$table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name');
$table->string('guard_name');
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
Schema::dropIfExists($tableNames['role_has_permissions']);
Schema::dropIfExists($tableNames['model_has_roles']);
Schema::dropIfExists($tableNames['model_has_permissions']);
Schema::dropIfExists($tableNames['roles']);
Schema::dropIfExists($tableNames['permissions']);
}
};

View File

@@ -0,0 +1,65 @@
<?php
namespace Database\Seeders;
use App\Models\DrawSession;
use App\Models\Member;
use App\Models\Prize;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call(RoleUserSeeder::class);
// Sesi cabutan aktif untuk event
DrawSession::firstOrCreate(
['is_active' => true],
['nama' => 'Mesyuarat Agung Tahunan KOIPB ' . date('Y')]
);
// 200 anggota dummy
if (Member::count() === 0) {
Member::factory()->count(200)->create();
}
$this->seedPrizes();
}
private function seedPrizes(): void
{
if (Prize::count() > 0) {
return;
}
// Definisi hadiah (kuantiti > 1 dipecah jadi item berasingan)
$definitions = [
['H01', 'Cabutan Utama - Umrah', 'Utama', 12000, 1, 1],
['H02', 'Motosikal', 'Utama', 6000, 2, 1],
['H03', 'Peti Sejuk 2 Pintu', 'Elektrik', 2500, 3, 1],
['H04', 'Mesin Basuh', 'Elektrik', 1800, 4, 1],
['H05', 'Smart TV 55"', 'Elektrik', 3200, 5, 1],
['H06', 'Telefon Pintar', 'Gajet', 2200, 6, 2],
['H07', 'Tablet', 'Gajet', 1500, 7, 2],
['H08', 'Basikal', 'Sukan', 900, 8, 2],
['H09', 'Hamper Premium', 'Hamper', 350, 9, 5],
['H10', 'Baucar Tunai RM200', 'Baucar', 200, 10, 5],
];
foreach ($definitions as [$kod, $nama, $kategori, $nilai, $drawOrder, $kuantiti]) {
for ($n = 1; $n <= $kuantiti; $n++) {
Prize::create([
'kod_hadiah' => $kod,
'nama_hadiah' => $kuantiti > 1 ? "{$nama} #{$n}" : $nama,
'kategori' => $kategori,
'nilai_anggaran' => $nilai,
'draw_order' => $drawOrder,
'item_index' => $n,
'batch_kod' => $kod,
'status' => Prize::STATUS_BELUM,
]);
}
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
class RoleUserSeeder extends Seeder
{
public function run(): void
{
$roles = [
User::ROLE_ADMIN,
User::ROLE_KAUNTER,
User::ROLE_CABUTAN,
];
foreach ($roles as $role) {
Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
}
$users = [
['Admin KOIPB', 'admin@koipb.test', User::ROLE_ADMIN],
['Petugas Kaunter', 'kaunter@koipb.test', User::ROLE_KAUNTER],
['Petugas Cabutan', 'cabutan@koipb.test', User::ROLE_CABUTAN],
];
foreach ($users as [$name, $email, $role]) {
$user = User::firstOrCreate(
['email' => $email],
['name' => $name, 'password' => Hash::make('password')]
);
$user->syncRoles([$role]);
}
}
}

47
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,47 @@
###############################################################################
# KOIPB MAT — Docker Compose Production Override (Ubuntu Server)
#
# Penggunaan:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# Perbezaan dari dev:
# • APP_ENV=production, APP_DEBUG=false → config/route/view cache
# • restart: always
# • php-dev.ini diganti dgn php-prod.ini (opcache.validate_timestamps=0)
# • Nginx dengar pada port 80
# • MySQL TIDAK dedah port ke host (akses dalaman sahaja)
###############################################################################
name: koipb
services:
mysql:
restart: always
ports: [] # jangan dedah MySQL ke host pada production
app:
restart: always
environment:
APP_ENV: production
APP_DEBUG: "false"
DB_HOST: mysql
DB_PORT: "3306"
volumes:
- ./:/var/www/html
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-koipb.ini:ro
- ./docker/php/php-prod.ini:/usr/local/etc/php/conf.d/99-koipb-prod.ini:ro
nginx:
restart: always
ports:
- "${APP_PORT:-80}:80"
# ── Node (opsional) ────────────────────────────────────────────────────────
# UI guna CDN + CSS statik (public/css/koipb.css) — TIADA langkah build Vite.
# Service ini hanya jika anda menambah aset yang perlu di-compile.
# node-build:
# image: node:lts-alpine
# working_dir: /app
# volumes: [ ./:/app ]
# command: sh -c "npm ci && npm run build"
# profiles: [ build ]

89
docker-compose.yml Normal file
View File

@@ -0,0 +1,89 @@
###############################################################################
# KOIPB MAT — Docker Compose (Development)
#
# Penggunaan:
# cp .env.docker.example .env # isi DB_PASSWORD / DB_ROOT_PASSWORD
# docker compose up -d --build
#
# Aplikasi : http://localhost:8000
# MySQL : service "mysql" (named volume koipb_db_data)
#
# NOTA: .env yang sama dibaca oleh Laravel (env_file) DAN oleh Docker Compose
# untuk gantian ${DB_*} pada service mysql. Pastikan DB_HOST=mysql.
###############################################################################
name: koipb
services:
# ── MySQL 8 ────────────────────────────────────────────────────────────────
mysql:
image: mysql:8.0
container_name: koipb_mysql
restart: unless-stopped
environment:
MYSQL_DATABASE: ${DB_DATABASE:-koipb_mat}
MYSQL_USER: ${DB_USERNAME:-koipb}
MYSQL_PASSWORD: ${DB_PASSWORD:-secret}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootsecret}
volumes:
- koipb_db_data:/var/lib/mysql
ports:
- "127.0.0.1:3307:3306" # akses dev dari host (elak conflict dgn MySQL tempatan 3306)
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-rootsecret}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- koipb
# ── PHP-FPM Application ─────────────────────────────────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
container_name: koipb_app
restart: unless-stopped
working_dir: /var/www/html
env_file:
- .env
environment:
APP_ENV: local
APP_DEBUG: "true"
DB_HOST: mysql # paksa ke container mysql, walau .env tersilap
DB_PORT: "3306"
volumes:
- ./:/var/www/html
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/99-koipb.ini:ro
- ./docker/php/php-dev.ini:/usr/local/etc/php/conf.d/99-koipb-dev.ini:ro
depends_on:
mysql:
condition: service_healthy
networks:
- koipb
# ── Nginx Web Server ────────────────────────────────────────────────────────
nginx:
image: nginx:1.27-alpine
container_name: koipb_nginx
restart: unless-stopped
ports:
- "${APP_PORT:-8000}:80"
volumes:
- ./:/var/www/html:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- koipb
###############################################################################
networks:
koipb:
driver: bridge
volumes:
koipb_db_data:
name: koipb_db_data
labels:
com.koipb.description: "MySQL data MAT KOIPB — JANGAN PADAM semasa event"

105
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,105 @@
#!/bin/sh
# ──────────────────────────────────────────────────────────────────────────────
# KOIPB MAT — Container Entrypoint
# Jalankan sebelum php-fpm bermula:
# 1. Install Composer deps (jika vendor tiada)
# 2. Tunggu MySQL
# 3. Generate APP_KEY jika tiada
# 4. Migrate + seed peranan/pengguna (idempotent)
# 5. Storage link + permissions
# 6. Cache (prod sahaja)
# ──────────────────────────────────────────────────────────────────────────────
set -e
echo ""
echo "╔════════════════════════════════════════╗"
echo "║ KOIPB MAT — Container Start ║"
echo "╚════════════════════════════════════════╝"
echo ""
# ── 1. Pasang Composer dependencies ───────────────────────────────────────────
if [ ! -d /var/www/html/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:-mysql}"
DB_PORT="${DB_PORT:-3306}"
DB_DATABASE="${DB_DATABASE:-koipb_mat}"
DB_USERNAME="${DB_USERNAME:-koipb}"
DB_PASSWORD="${DB_PASSWORD:-secret}"
echo "⏳ Menunggu MySQL di ${DB_HOST}:${DB_PORT}..."
until php -r "
try {
new PDO('mysql:host=${DB_HOST};port=${DB_PORT};dbname=${DB_DATABASE}', '${DB_USERNAME}', '${DB_PASSWORD}');
exit(0);
} catch (Exception \$e) { exit(1); }
" 2>/dev/null; do
printf "."
sleep 2
done
echo ""
echo "✓ MySQL bersedia."
# ── 2b. Fix storage permissions ───────────────────────────────────────────────
mkdir -p /var/www/html/storage/framework/views \
/var/www/html/storage/framework/cache/data \
/var/www/html/storage/framework/sessions \
/var/www/html/storage/logs \
/var/www/html/storage/app/public \
/var/www/html/bootstrap/cache
chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true
chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true
# ── 3. Generate APP_KEY jika kosong ───────────────────────────────────────────
if [ -z "${APP_KEY}" ]; then
echo "🔑 Menjana APP_KEY..."
php artisan key:generate --force
fi
# Penting: env_file mungkin hantar APP_KEY="" (kosong) sebagai env var sebenar
# yang mengatasi nilai dalam .env. Baca semula dari .env & EXPORT supaya
# proses anak (migrate, config:cache, php-fpm) mewarisi kunci yang betul.
if [ -f /var/www/html/.env ]; then
FILE_KEY=$(grep -E '^APP_KEY=' /var/www/html/.env | head -1 | cut -d= -f2-)
if [ -n "${FILE_KEY}" ]; then
export APP_KEY="${FILE_KEY}"
fi
fi
# ── 4. Migration + seed peranan & pengguna (idempotent) ───────────────────────
echo "🗄 Menjalankan migration..."
php artisan migrate --force
echo "👤 Seeding peranan & pengguna (RoleUserSeeder)..."
php artisan db:seed --class=RoleUserSeeder --force
# Seed data demo (200 anggota + 21 hadiah) hanya bila SEED_DEMO=true
if [ "${SEED_DEMO}" = "true" ]; then
echo "🎁 Seeding data demo penuh (DatabaseSeeder)..."
php artisan db:seed --force
fi
# ── 5. Storage symbolic link ──────────────────────────────────────────────────
php artisan storage:link 2>/dev/null || true
# ── 6. Cache (production sahaja) ──────────────────────────────────────────────
if [ "${APP_ENV}" = "production" ]; then
echo "⚡ Caching config, routes, views..."
php artisan config:cache
php artisan route:cache
php artisan view:cache
else
php artisan optimize:clear 2>/dev/null || true
fi
echo ""
echo "✅ Aplikasi bersedia."
echo ""
exec "$@"

75
docker/nginx/default.conf Normal file
View File

@@ -0,0 +1,75 @@
# ──────────────────────────────────────────────────────────────────────────────
# KOIPB MAT — Nginx Server Block
# Document root: /var/www/html/public | PHP-FPM upstream: app:9000
# ──────────────────────────────────────────────────────────────────────────────
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types
text/plain text/css text/javascript application/javascript
application/json application/xml image/svg+xml font/woff2;
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
# Had muat naik (kena sama dengan php.ini: post_max_size)
client_max_body_size 25M;
charset utf-8;
# ── Security headers ──────────────────────────────────────────────────────
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ── Laravel routes ────────────────────────────────────────────────────────
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# ── PHP-FPM ───────────────────────────────────────────────────────────────
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_read_timeout 120s;
fastcgi_connect_timeout 10s;
fastcgi_buffer_size 16k;
fastcgi_buffers 8 16k;
}
# ── Static assets — cache 1 tahun ─────────────────────────────────────────
location ~* \.(jpg|jpeg|png|gif|ico|svg|css|js|woff2?|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
# ── Halang akses fail tersembunyi ─────────────────────────────────────────
location ~ /\. {
deny all;
}
# ── Halang akses terus ke fail sensitif ───────────────────────────────────
location ~* \.(env|log|htaccess|htpasswd|ini|sh|sql|bak)$ {
deny all;
}
access_log /var/log/nginx/koipb-access.log;
error_log /var/log/nginx/koipb-error.log warn;
}

13
docker/php/php-dev.ini Normal file
View File

@@ -0,0 +1,13 @@
; ──────────────────────────────────────────────────────────────────────────────
; KOIPB MAT — Development overrides (dimuat selepas php.ini)
; Hanya dimuat dalam docker-compose.yml (dev), tidak dalam prod
; ──────────────────────────────────────────────────────────────────────────────
; Reload fail PHP tanpa restart container
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
; Tunjuk ralat semasa pembangunan
display_errors = On
display_startup_errors = On
error_reporting = E_ALL

11
docker/php/php-prod.ini Normal file
View File

@@ -0,0 +1,11 @@
; ──────────────────────────────────────────────────────────────────────────────
; KOIPB MAT — Production overrides (dimuat selepas php.ini)
; Hanya dimuat dalam docker-compose.prod.yml
; ──────────────────────────────────────────────────────────────────────────────
; Prestasi: jangan semak timestamp fail (kosongkan cache bila deploy)
opcache.validate_timestamps = 0
; Sembunyikan ralat dari pengguna
display_errors = Off
display_startup_errors = Off

45
docker/php/php.ini Normal file
View File

@@ -0,0 +1,45 @@
; ──────────────────────────────────────────────────────────────────────────────
; KOIPB MAT — PHP Runtime Configuration
; Letakkan dalam /usr/local/etc/php/conf.d/99-koipb.ini
; ──────────────────────────────────────────────────────────────────────────────
; Timezone Malaysia
date.timezone = Asia/Kuala_Lumpur
; ── Muat naik (import CSV/XLSX anggota & hadiah) ──────────────────────────────
upload_max_filesize = 20M
post_max_size = 25M
max_file_uploads = 10
; ── Pelaksanaan ───────────────────────────────────────────────────────────────
max_execution_time = 120
max_input_time = 120
; ── Memori (eksport PDF/XLSX) ─────────────────────────────────────────────────
memory_limit = 512M
; ── Session ───────────────────────────────────────────────────────────────────
session.cookie_httponly = 1
session.cookie_samesite = Lax
session.gc_maxlifetime = 7200
; ── OPcache ───────────────────────────────────────────────────────────────────
opcache.enable = 1
opcache.memory_consumption = 192
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000
; validate_timestamps: 1 untuk dev (auto-reload), override 0 untuk prod
opcache.validate_timestamps = 1
opcache.revalidate_freq = 2
opcache.fast_shutdown = 1
; ── Ralat (default selamat — override per environment) ────────────────────────
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
output_buffering = 4096
sys_temp_dir = /tmp
upload_tmp_dir = /tmp

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1",
"tailwindcss": "^4.0.0",
"vite": "^8.0.0"
}
}

36
phpunit.xml Normal file
View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

25
public/.htaccess Normal file
View File

@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

137
public/css/koipb.css Normal file
View File

@@ -0,0 +1,137 @@
:root {
--koipb-teal: #0a8f9c;
--koipb-teal-dark: #086e78;
--koipb-teal-light: #1bb6c4;
--koipb-cyan: #14b8c8;
--koipb-dark: #2b3036;
--koipb-dark-2: #1c2024;
--koipb-gold: #e0a91b;
--koipb-green: #1e9e5a;
--koipb-red: #c1272d;
--koipb-bg: #f4f7f8;
}
* { -webkit-tap-highlight-color: transparent; }
body {
background: var(--koipb-bg);
font-family: "Segoe UI", system-ui, -apple-system, Roboto, "Helvetica Neue", Arial, sans-serif;
color: #23282d;
}
/* ---------- Sidebar layout ---------- */
.koipb-shell { display: flex; min-height: 100vh; }
.koipb-sidebar {
width: 250px; flex-shrink: 0;
background: linear-gradient(180deg, var(--koipb-dark) 0%, var(--koipb-dark-2) 100%);
color: #cfd6dc; position: fixed; top: 0; bottom: 0; left: 0; z-index: 1040;
display: flex; flex-direction: column; transition: transform .25s ease;
}
.koipb-sidebar .brand {
padding: 18px 16px; display: flex; align-items: center; gap: 12px;
border-bottom: 1px solid rgba(255,255,255,.08);
}
.koipb-sidebar .brand img { width: 46px; height: 46px; border-radius: 10px; background:#fff; object-fit: contain; padding:3px; }
.koipb-sidebar .brand .t1 { font-weight: 700; color: #fff; font-size: 1rem; line-height: 1.1; }
.koipb-sidebar .brand .t2 { font-size: .72rem; color: var(--koipb-teal-light); letter-spacing: .5px; }
.koipb-nav { padding: 10px 8px; overflow-y: auto; flex: 1; }
.koipb-nav .nav-section { font-size: .68rem; text-transform: uppercase; letter-spacing: 1px; color: #6b7882; padding: 14px 12px 6px; }
.koipb-nav a {
display: flex; align-items: center; gap: 11px; padding: 10px 12px; border-radius: 9px;
color: #c2cbd2; text-decoration: none; font-size: .92rem; margin-bottom: 2px; transition: all .15s;
}
.koipb-nav a:hover { background: rgba(255,255,255,.06); color: #fff; }
.koipb-nav a.active { background: var(--koipb-teal); color: #fff; box-shadow: 0 4px 12px rgba(10,143,156,.4); }
.koipb-nav a .ico { width: 20px; text-align: center; }
.koipb-sidebar .sb-foot { padding: 12px 14px; border-top: 1px solid rgba(255,255,255,.08); font-size:.8rem; }
.koipb-main { flex: 1; margin-left: 250px; min-width: 0; display: flex; flex-direction: column; }
.koipb-topbar {
background: #fff; border-bottom: 1px solid #e3e9ec; padding: 12px 22px;
display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 1030;
}
.koipb-topbar h1 { font-size: 1.15rem; font-weight: 700; margin: 0; color: var(--koipb-dark); }
.koipb-content { padding: 22px; flex: 1; }
.koipb-burger { display: none; }
@media (max-width: 991px) {
.koipb-sidebar { transform: translateX(-100%); }
.koipb-sidebar.show { transform: translateX(0); }
.koipb-main { margin-left: 0; }
.koipb-burger { display: inline-flex; }
.koipb-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 1035; }
}
/* ---------- Cards / stats ---------- */
.card { border: none; border-radius: 14px; box-shadow: 0 2px 10px rgba(20,40,50,.06); }
.stat-card { border-radius: 14px; padding: 18px 20px; color: #fff; position: relative; overflow: hidden; }
.stat-card .label { font-size: .82rem; opacity: .92; font-weight: 500; }
.stat-card .value { font-size: 2rem; font-weight: 800; line-height: 1.1; }
.stat-card .ico { position: absolute; right: 14px; top: 12px; font-size: 2.4rem; opacity: .22; }
.bg-teal { background: linear-gradient(135deg, var(--koipb-teal) 0%, var(--koipb-teal-dark) 100%); }
.bg-dark2 { background: linear-gradient(135deg, #3a4149 0%, var(--koipb-dark-2) 100%); }
.bg-green { background: linear-gradient(135deg, #25b366 0%, #16834a 100%); }
.bg-gold { background: linear-gradient(135deg, #f0bb3a 0%, var(--koipb-gold) 100%); }
.bg-red { background: linear-gradient(135deg, #d83b41 0%, var(--koipb-red) 100%); }
.bg-cyan { background: linear-gradient(135deg, #1bc6d6 0%, var(--koipb-teal) 100%); }
.text-teal { color: var(--koipb-teal) !important; }
.btn-teal { background: var(--koipb-teal); border-color: var(--koipb-teal); color: #fff; }
.btn-teal:hover { background: var(--koipb-teal-dark); border-color: var(--koipb-teal-dark); color: #fff; }
.btn-outline-teal { color: var(--koipb-teal); border-color: var(--koipb-teal); }
.btn-outline-teal:hover { background: var(--koipb-teal); color: #fff; }
.badge-hadir { background: var(--koipb-green); }
.badge-belum { background: #94a3b8; }
/* status pills */
.pill { display:inline-block; padding: 3px 11px; border-radius: 999px; font-size: .76rem; font-weight: 600; }
.pill-belum { background:#e6edf0; color:#516470; }
.pill-sedang { background:#fff3cd; color:#8a6d00; }
.pill-disahkan { background:#d6f4e2; color:#10713f; }
.pill-redraw { background:#ffe1e1; color:#a01c22; }
/* ---------- Counter screen ---------- */
.counter-search input {
font-size: 1.35rem; padding: 18px 22px; border-radius: 14px; border: 2px solid #d4dde2;
}
.counter-search input:focus { border-color: var(--koipb-teal); box-shadow: 0 0 0 .25rem rgba(10,143,156,.18); }
.result-card { border-left: 5px solid var(--koipb-teal); transition: transform .1s; }
.result-card.hadir { border-left-color: var(--koipb-green); }
/* ---------- Login ---------- */
.login-wrap { min-height: 100vh; display:flex; align-items:center; justify-content:center;
background: linear-gradient(135deg, var(--koipb-teal-dark) 0%, var(--koipb-dark) 100%); padding: 20px; }
.login-card { width: 100%; max-width: 420px; border-radius: 18px; overflow:hidden; }
.login-card .head { background: #fff; padding: 28px 28px 6px; text-align:center; }
.login-card .head img { width: 84px; height: 84px; object-fit: contain; }
/* ---------- Draw / Projector ---------- */
.projector { background: radial-gradient(circle at 30% 20%, #0c3a42 0%, #071f24 70%); min-height: 100vh; color:#fff; }
.draw-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 22px; }
@media (max-width: 1100px){ .draw-grid { grid-template-columns: 1fr; } }
.wheel-stage { display:flex; flex-direction:column; align-items:center; justify-content:center; }
#wheelCanvas { max-width: 100%; height: auto; filter: drop-shadow(0 12px 30px rgba(0,0,0,.5)); }
.wheel-pointer { font-size: 2.4rem; color: var(--koipb-gold); margin-bottom: -14px; z-index:2; }
.panel-result {
background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.12);
border-radius: 20px; padding: 26px; min-height: 480px; display:flex; flex-direction:column;
transition: background .4s, border-color .4s;
}
.panel-result.win { background: linear-gradient(160deg, rgba(30,158,90,.35), rgba(224,169,27,.28)); border-color: var(--koipb-gold); }
.panel-result.cancel { background: linear-gradient(160deg, rgba(120,20,24,.55), rgba(40,44,48,.6)); border-color: #7a2226; }
.winner-name { font-size: 2.8rem; font-weight: 800; line-height: 1.05; }
.prize-title { font-size: 1.5rem; font-weight: 700; color: var(--koipb-gold); }
.big-msg { font-size: 3rem; font-weight: 900; letter-spacing: 1px; }
.btn-spin {
font-size: 1.5rem; font-weight: 800; padding: 14px 50px; border-radius: 999px;
background: linear-gradient(135deg, var(--koipb-gold), #c98a09); border:none; color:#1c1100;
box-shadow: 0 8px 24px rgba(224,169,27,.45);
}
.btn-spin:disabled { opacity: .5; }
.draw-topbar { display:flex; align-items:center; justify-content:space-between; padding: 14px 22px;
background: rgba(0,0,0,.25); border-bottom:1px solid rgba(255,255,255,.08); }
.draw-topbar img { height: 44px; background:#fff; border-radius:8px; padding:3px; }
table.table-koipb thead th { background: var(--koipb-dark); color:#fff; font-weight:600; font-size:.85rem; }
table.table-koipb tbody td { vertical-align: middle; }

0
public/favicon.ico Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

20
public/index.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

View File

@@ -0,0 +1,6 @@
no_anggota,no_pekerja,no_kp,nama,jabatan,bahagian,telefon,status_aktif
A00001,MBIP00001,850101-01-5523,Ahmad bin Ali,Kewangan,Unit A,012-3456789,aktif
A00002,MBIP00002,880215-14-6644,Siti binti Yusof,Khidmat Pengurusan,Pentadbiran,013-2233445,aktif
A00003,MBIP00003,790320-10-1122,Lim Chee Keong,Teknologi Maklumat,Operasi,014-9988776,aktif
A00004,MBIP00004,910707-08-3344,Nurul Huda binti Razak,Perancangan,Unit B,011-22334455,aktif
A00005,MBIP00005,830512-12-7788,Rajesh a/l Kumar,Kejuruteraan,Sokongan,019-8765432,tidak aktif
1 no_anggota no_pekerja no_kp nama jabatan bahagian telefon status_aktif
2 A00001 MBIP00001 850101-01-5523 Ahmad bin Ali Kewangan Unit A 012-3456789 aktif
3 A00002 MBIP00002 880215-14-6644 Siti binti Yusof Khidmat Pengurusan Pentadbiran 013-2233445 aktif
4 A00003 MBIP00003 790320-10-1122 Lim Chee Keong Teknologi Maklumat Operasi 014-9988776 aktif
5 A00004 MBIP00004 910707-08-3344 Nurul Huda binti Razak Perancangan Unit B 011-22334455 aktif
6 A00005 MBIP00005 830512-12-7788 Rajesh a/l Kumar Kejuruteraan Sokongan 019-8765432 tidak aktif

View File

@@ -0,0 +1,7 @@
kod_hadiah,nama_hadiah,kategori,nilai_anggaran,susunan_cabutan,kuantiti
H01,Cabutan Utama - Umrah,Utama,12000,1,1
H02,Motosikal,Utama,6000,2,1
H03,Smart TV 55 inci,Elektrik,3200,3,1
H06,Telefon Pintar,Gajet,2200,4,2
H09,Hamper Premium,Hamper,350,5,5
H10,Baucar Tunai RM200,Baucar,200,6,5
1 kod_hadiah nama_hadiah kategori nilai_anggaran susunan_cabutan kuantiti
2 H01 Cabutan Utama - Umrah Utama 12000 1 1
3 H02 Motosikal Utama 6000 2 1
4 H03 Smart TV 55 inci Elektrik 3200 3 1
5 H06 Telefon Pintar Gajet 2200 4 2
6 H09 Hamper Premium Hamper 350 5 5
7 H10 Baucar Tunai RM200 Baucar 200 6 5

9
resources/css/app.css Normal file
View File

@@ -0,0 +1,9 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

1
resources/js/app.js Normal file
View File

@@ -0,0 +1 @@
//

View File

@@ -0,0 +1,36 @@
@extends('layouts.app')
@section('title', 'Audit Trail')
@section('content')
<form class="mb-3" method="GET">
<div class="input-group" style="max-width:360px;">
<select name="action" class="form-select">
<option value="">Semua Tindakan</option>
@foreach($actions as $a)<option value="{{ $a }}" @selected($action===$a)>{{ $a }}</option>@endforeach
</select>
<button class="btn btn-teal"><i class="bi bi-funnel"></i></button>
</div>
</form>
<div class="card"><div class="card-body">
<div class="table-responsive">
<table class="table table-koipb table-hover align-middle">
<thead><tr><th>Masa</th><th>Pengguna</th><th>Tindakan</th><th>Keterangan</th><th>IP</th></tr></thead>
<tbody>
@forelse($logs as $log)
<tr>
<td><small>{{ $log->created_at->format('d/m/Y H:i:s') }}</small></td>
<td>{{ $log->user?->name ?? 'Sistem' }}</td>
<td><span class="badge bg-dark2">{{ $log->action }}</span></td>
<td>{{ $log->description }}</td>
<td><small class="text-muted">{{ $log->ip_address ?? '-' }}</small></td>
</tr>
@empty
<tr><td colspan="5" class="text-center text-muted py-4">Tiada rekod audit.</td></tr>
@endforelse
</tbody>
</table>
</div>
{{ $logs->links() }}
</div></div>
@endsection

View File

@@ -0,0 +1,40 @@
@extends('layouts.app')
@section('title', 'Kemaskini Anggota')
@section('content')
<div class="row justify-content-center"><div class="col-lg-8">
<div class="card"><div class="card-body">
<form method="POST" action="{{ route('admin.members.update', $member) }}">
@csrf @method('PUT')
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Nama *</label>
<input name="nama" class="form-control @error('nama') is-invalid @enderror" value="{{ old('nama', $member->nama) }}" required>
@error('nama')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">No Anggota</label>
<input name="no_anggota" class="form-control" value="{{ old('no_anggota', $member->no_anggota) }}"></div>
<div class="col-md-6"><label class="form-label">No Pekerja</label>
<input name="no_pekerja" class="form-control @error('no_pekerja') is-invalid @enderror" value="{{ old('no_pekerja', $member->no_pekerja) }}">
@error('no_pekerja')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">No KP</label>
<input name="no_kp" class="form-control @error('no_kp') is-invalid @enderror" value="{{ old('no_kp', $member->no_kp) }}">
@error('no_kp')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-6"><label class="form-label">Jabatan</label>
<input name="jabatan" class="form-control" value="{{ old('jabatan', $member->jabatan) }}"></div>
<div class="col-md-6"><label class="form-label">Bahagian</label>
<input name="bahagian" class="form-control" value="{{ old('bahagian', $member->bahagian) }}"></div>
<div class="col-md-6"><label class="form-label">Telefon</label>
<input name="telefon" class="form-control" value="{{ old('telefon', $member->telefon) }}"></div>
<div class="col-md-6"><label class="form-label">Status</label>
<select name="status_aktif" class="form-select">
<option value="1" @selected($member->status_aktif)>Aktif</option>
<option value="0" @selected(!$member->status_aktif)>Tidak Aktif</option>
</select></div>
</div>
<div class="mt-4 d-flex gap-2">
<button class="btn btn-teal"><i class="bi bi-save"></i> Simpan</button>
<a href="{{ route('admin.members.index') }}" class="btn btn-light">Kembali</a>
</div>
</form>
</div></div>
</div></div>
@endsection

View File

@@ -0,0 +1,55 @@
@extends('layouts.app')
@section('title', 'Import Anggota')
@section('content')
@php $result = session('import_result') ? \App\Models\ImportLog::find(session('import_result')) : null; @endphp
<div class="row g-3">
<div class="col-lg-5">
<div class="card"><div class="card-body">
<h5 class="fw-bold"><i class="bi bi-upload text-teal"></i> Muat Naik Fail</h5>
<p class="text-muted small">Format CSV atau XLSX. Lajur: <code>no_anggota, no_pekerja, no_kp, nama, jabatan, bahagian, telefon, status_aktif</code>. Hanya <b>nama</b> wajib.</p>
<form method="POST" action="{{ route('admin.members.import.store') }}" enctype="multipart/form-data">
@csrf
<input type="file" name="file" class="form-control mb-2 @error('file') is-invalid @enderror" accept=".csv,.xlsx,.xls" required>
@error('file')<div class="invalid-feedback">{{ $message }}</div>@enderror
<button class="btn btn-teal w-100"><i class="bi bi-cloud-upload"></i> Import Sekarang</button>
</form>
<a href="{{ asset('samples/contoh-anggota.csv') }}" class="btn btn-sm btn-link mt-2"><i class="bi bi-download"></i> Muat turun templat contoh</a>
</div></div>
</div>
<div class="col-lg-7">
@if($result)
<div class="card mb-3 border-start border-4 border-success"><div class="card-body">
<h6 class="fw-bold">Ringkasan Import: {{ $result->filename }}</h6>
<div class="row text-center g-2 my-2">
<div class="col"><div class="stat-card bg-teal py-2"><div class="value fs-4">{{ $result->total_rows }}</div><div class="label">Jumlah Baris</div></div></div>
<div class="col"><div class="stat-card bg-green py-2"><div class="value fs-4">{{ $result->success_count }}</div><div class="label">Berjaya</div></div></div>
<div class="col"><div class="stat-card bg-gold py-2"><div class="value fs-4">{{ $result->duplicate_count }}</div><div class="label">Duplicate</div></div></div>
<div class="col"><div class="stat-card bg-red py-2"><div class="value fs-4">{{ $result->failed_count }}</div><div class="label">Gagal</div></div></div>
</div>
@if($result->errors)
<details><summary class="text-danger">Lihat {{ count($result->errors) }} isu</summary>
<ul class="small mt-2 mb-0">@foreach($result->errors as $e)<li>{{ $e }}</li>@endforeach</ul>
</details>
@endif
</div></div>
@endif
<div class="card"><div class="card-body">
<h6 class="fw-bold"><i class="bi bi-clock-history"></i> Log Import Terkini</h6>
<table class="table table-sm align-middle">
<thead><tr><th>Fail</th><th>Berjaya</th><th>Dup</th><th>Gagal</th><th>Oleh</th><th>Masa</th></tr></thead>
<tbody>
@forelse($logs as $log)
<tr><td>{{ $log->filename }}</td><td class="text-success">{{ $log->success_count }}</td>
<td class="text-warning">{{ $log->duplicate_count }}</td><td class="text-danger">{{ $log->failed_count }}</td>
<td>{{ $log->importedBy?->name ?? '-' }}</td><td><small>{{ $log->created_at->format('d/m H:i') }}</small></td></tr>
@empty
<tr><td colspan="6" class="text-center text-muted">Belum ada import.</td></tr>
@endforelse
</tbody>
</table>
</div></div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,49 @@
@extends('layouts.app')
@section('title', 'Urus Anggota')
@section('content')
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<form class="d-flex gap-2" method="GET" style="max-width:420px;">
<input type="text" name="q" value="{{ $q }}" class="form-control" placeholder="Cari nama / no pekerja / KP...">
<button class="btn btn-teal"><i class="bi bi-search"></i></button>
</form>
<a href="{{ route('admin.members.import') }}" class="btn btn-outline-teal"><i class="bi bi-upload"></i> Import Anggota</a>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-koipb table-hover align-middle">
<thead><tr>
<th>No Anggota</th><th>Nama</th><th>No Pekerja</th><th>No KP</th><th>Jabatan</th><th>Status</th><th>Hadir</th><th></th>
</tr></thead>
<tbody>
@forelse($members as $m)
<tr>
<td>{{ $m->no_anggota ?? '-' }}</td>
<td class="fw-semibold">{{ $m->nama }}
@if($m->confirmed_wins) <span class="badge bg-gold"><i class="bi bi-trophy"></i> Menang</span> @endif
</td>
<td>{{ $m->no_pekerja ?? '-' }}</td>
<td><small class="text-muted">{{ $m->maskedKp() ?? '-' }}</small></td>
<td>{{ $m->jabatan ?? '-' }}</td>
<td>@if($m->status_aktif)<span class="pill pill-disahkan">Aktif</span>@else<span class="pill pill-redraw">Tidak Aktif</span>@endif</td>
<td>@if($m->attendance)<span class="badge badge-hadir">Hadir</span>@else<span class="badge badge-belum">Belum</span>@endif</td>
<td class="text-end">
<a href="{{ route('admin.members.edit', $m) }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-pencil"></i></a>
<form action="{{ route('admin.members.destroy', $m) }}" method="POST" class="d-inline" onsubmit="return confirm('Padam anggota {{ $m->nama }}?')">
@csrf @method('DELETE')
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
@empty
<tr><td colspan="8" class="text-center text-muted py-4">Tiada anggota. Sila import senarai anggota.</td></tr>
@endforelse
</tbody>
</table>
</div>
{{ $members->links() }}
</div>
</div>
@endsection

View File

@@ -0,0 +1,29 @@
@extends('layouts.app')
@section('title', 'Kemaskini Hadiah')
@section('content')
<div class="row justify-content-center"><div class="col-lg-7">
<div class="card"><div class="card-body">
<form method="POST" action="{{ route('admin.prizes.update', $prize) }}">
@csrf @method('PUT')
<div class="row g-3">
<div class="col-md-8"><label class="form-label">Nama Hadiah *</label>
<input name="nama_hadiah" class="form-control @error('nama_hadiah') is-invalid @enderror" value="{{ old('nama_hadiah', $prize->nama_hadiah) }}" required>
@error('nama_hadiah')<div class="invalid-feedback">{{ $message }}</div>@enderror</div>
<div class="col-md-4"><label class="form-label">Susunan Cabutan *</label>
<input type="number" name="draw_order" class="form-control" value="{{ old('draw_order', $prize->draw_order) }}" required></div>
<div class="col-md-4"><label class="form-label">Kod</label>
<input name="kod_hadiah" class="form-control" value="{{ old('kod_hadiah', $prize->kod_hadiah) }}"></div>
<div class="col-md-4"><label class="form-label">Kategori</label>
<input name="kategori" class="form-control" value="{{ old('kategori', $prize->kategori) }}"></div>
<div class="col-md-4"><label class="form-label">Nilai Anggaran (RM)</label>
<input type="number" step="0.01" name="nilai_anggaran" class="form-control" value="{{ old('nilai_anggaran', $prize->nilai_anggaran) }}"></div>
</div>
<div class="mt-4 d-flex gap-2">
<button class="btn btn-teal"><i class="bi bi-save"></i> Simpan</button>
<a href="{{ route('admin.prizes.index') }}" class="btn btn-light">Kembali</a>
</div>
</form>
</div></div>
</div></div>
@endsection

View File

@@ -0,0 +1,51 @@
@extends('layouts.app')
@section('title', 'Import Hadiah')
@section('content')
@php $result = session('import_result') ? \App\Models\ImportLog::find(session('import_result')) : null; @endphp
<div class="row g-3">
<div class="col-lg-5">
<div class="card"><div class="card-body">
<h5 class="fw-bold"><i class="bi bi-upload text-teal"></i> Muat Naik Fail</h5>
<p class="text-muted small">Format CSV atau XLSX. Lajur: <code>kod_hadiah, nama_hadiah, kategori, nilai_anggaran, susunan_cabutan, kuantiti</code>.
Jika <b>kuantiti &gt; 1</b>, sistem akan jana beberapa item berasingan (cth Hamper #1 … #5).</p>
<form method="POST" action="{{ route('admin.prizes.import.store') }}" enctype="multipart/form-data">
@csrf
<input type="file" name="file" class="form-control mb-2 @error('file') is-invalid @enderror" accept=".csv,.xlsx,.xls" required>
@error('file')<div class="invalid-feedback">{{ $message }}</div>@enderror
<button class="btn btn-teal w-100"><i class="bi bi-cloud-upload"></i> Import Sekarang</button>
</form>
<a href="{{ asset('samples/contoh-hadiah.csv') }}" class="btn btn-sm btn-link mt-2"><i class="bi bi-download"></i> Muat turun templat contoh</a>
</div></div>
</div>
<div class="col-lg-7">
@if($result)
<div class="card mb-3 border-start border-4 border-success"><div class="card-body">
<h6 class="fw-bold">Ringkasan Import: {{ $result->filename }}</h6>
<div class="row text-center g-2 my-2">
<div class="col"><div class="stat-card bg-teal py-2"><div class="value fs-4">{{ $result->total_rows }}</div><div class="label">Baris</div></div></div>
<div class="col"><div class="stat-card bg-green py-2"><div class="value fs-4">{{ $result->success_count }}</div><div class="label">Item Dijana</div></div></div>
<div class="col"><div class="stat-card bg-red py-2"><div class="value fs-4">{{ $result->failed_count }}</div><div class="label">Gagal</div></div></div>
</div>
@if($result->errors)
<details><summary class="text-danger">Lihat {{ count($result->errors) }} isu</summary>
<ul class="small mt-2 mb-0">@foreach($result->errors as $e)<li>{{ $e }}</li>@endforeach</ul></details>
@endif
</div></div>
@endif
<div class="card"><div class="card-body">
<h6 class="fw-bold"><i class="bi bi-clock-history"></i> Log Import Terkini</h6>
<table class="table table-sm align-middle">
<thead><tr><th>Fail</th><th>Item</th><th>Gagal</th><th>Oleh</th><th>Masa</th></tr></thead>
<tbody>
@forelse($logs as $log)
<tr><td>{{ $log->filename }}</td><td class="text-success">{{ $log->success_count }}</td>
<td class="text-danger">{{ $log->failed_count }}</td><td>{{ $log->importedBy?->name ?? '-' }}</td>
<td><small>{{ $log->created_at->format('d/m H:i') }}</small></td></tr>
@empty<tr><td colspan="5" class="text-center text-muted">Belum ada import.</td></tr>@endforelse
</tbody>
</table>
</div></div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,42 @@
@extends('layouts.app')
@section('title', 'Urus Hadiah')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="text-muted">Susunan cabutan mengikut <code>draw_order</code>.</div>
<a href="{{ route('admin.prizes.import') }}" class="btn btn-outline-teal"><i class="bi bi-upload"></i> Import Hadiah</a>
</div>
<div class="card"><div class="card-body">
<div class="table-responsive">
<table class="table table-koipb table-hover align-middle">
<thead><tr><th>Susunan</th><th>Kod</th><th>Hadiah</th><th>Kategori</th><th>Nilai (RM)</th><th>Status</th><th>Pemenang</th><th></th></tr></thead>
<tbody>
@forelse($prizes as $p)
@php $pill = ['belum_dicabut'=>'pill-belum','sedang_dicabut'=>'pill-sedang','disahkan'=>'pill-disahkan','redraw_required'=>'pill-redraw'][$p->status]; @endphp
<tr>
<td><span class="badge bg-dark2">#{{ $p->draw_order }}</span></td>
<td>{{ $p->kod_hadiah ?? '-' }}</td>
<td class="fw-semibold">{{ $p->nama_hadiah }}</td>
<td>{{ $p->kategori ?? '-' }}</td>
<td>{{ $p->nilai_anggaran ? number_format($p->nilai_anggaran, 2) : '-' }}</td>
<td><span class="pill {{ $pill }}">{{ $p->statusLabel() }}</span>
@if($p->redraw_count) <small class="text-muted">({{ $p->redraw_count }}x redraw)</small>@endif</td>
<td>{{ $p->drawResults->firstWhere('status','confirmed')?->member?->nama ?? '-' }}</td>
<td class="text-end">
<a href="{{ route('admin.prizes.edit', $p) }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-pencil"></i></a>
<form action="{{ route('admin.prizes.destroy', $p) }}" method="POST" class="d-inline" onsubmit="return confirm('Padam hadiah ini?')">
@csrf @method('DELETE')
<button class="btn btn-sm btn-outline-danger" @disabled($p->status==='disahkan')><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
@empty
<tr><td colspan="8" class="text-center text-muted py-4">Tiada hadiah. Sila import senarai hadiah.</td></tr>
@endforelse
</tbody>
</table>
</div>
{{ $prizes->links() }}
</div></div>
@endsection

View File

@@ -0,0 +1,40 @@
@extends('layouts.app')
@section('title', 'Tetapan & Reset')
@section('content')
<div class="row g-3">
<div class="col-lg-6">
<div class="card"><div class="card-body">
<h5 class="fw-bold"><i class="bi bi-gear text-teal"></i> Tetapan Sesi Cabutan</h5>
<form method="POST" action="{{ route('admin.settings.session') }}">
@csrf @method('PUT')
<div class="mb-3"><label class="form-label">Nama Sesi / Event</label>
<input name="nama" class="form-control" value="{{ old('nama', $session->nama) }}" required></div>
<div class="mb-3"><label class="form-label">Peserta dibatalkan (tiada di dewan)</label>
<select name="return_cancelled_to_pool" class="form-select">
<option value="1" @selected($session->return_cancelled_to_pool)>Masukkan semula ke pool cabutan (default)</option>
<option value="0" @selected(!$session->return_cancelled_to_pool)>Keluarkan terus dari pool</option>
</select></div>
<button class="btn btn-teal"><i class="bi bi-save"></i> Simpan Tetapan</button>
</form>
</div></div>
</div>
<div class="col-lg-6">
<div class="card border-danger"><div class="card-body">
<h5 class="fw-bold text-danger"><i class="bi bi-exclamation-triangle"></i> Reset Data Event</h5>
<p class="text-muted small">Untuk mula event baharu. Tindakan ini tidak boleh diundur.</p>
<form method="POST" action="{{ route('admin.settings.reset') }}" id="resetForm">
@csrf
<div class="mb-3"><label class="form-label">Skop Reset</label>
<select name="scope" class="form-select">
<option value="draw">Reset cabutan sahaja (kekalkan kehadiran)</option>
<option value="all">Reset semua (kehadiran + cabutan)</option>
</select></div>
<div class="mb-3"><label class="form-label">Taip <code>RESET</code> untuk sahkan</label>
<input name="confirm" class="form-control" placeholder="RESET" autocomplete="off" required></div>
<button class="btn btn-danger" onclick="return confirm('Anda pasti mahu reset data event? Tindakan ini kekal.')"><i class="bi bi-trash"></i> Reset Sekarang</button>
</form>
</div></div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,69 @@
@extends('layouts.app')
@section('title', 'Senarai Kehadiran')
@section('content')
<div class="row g-2 mb-3">
<div class="col-6 col-md-3"><div class="stat-card bg-green"><div class="label">Sudah Hadir</div><div class="value">{{ $counts['hadir'] }}</div></div></div>
<div class="col-6 col-md-3"><div class="stat-card bg-dark2"><div class="label">Belum Hadir</div><div class="value">{{ $counts['belum'] }}</div></div></div>
<div class="col-6 col-md-3"><div class="stat-card bg-teal"><div class="label">Jumlah Anggota</div><div class="value">{{ $counts['semua'] }}</div></div></div>
<div class="col-6 col-md-3 d-flex align-items-end">
<a href="{{ route('reports.export', 'attendance') }}" class="btn btn-outline-teal w-100"><i class="bi bi-file-earmark-excel"></i> Export Excel</a>
</div>
</div>
<div class="card">
<div class="card-body">
<form class="row g-2 mb-3" method="GET">
<div class="col-md-4">
<input type="text" name="q" value="{{ $q }}" class="form-control" placeholder="Cari nama / no pekerja / KP...">
</div>
<div class="col-md-3">
<select name="status" class="form-select">
<option value="hadir" @selected($status==='hadir')>Sudah Hadir</option>
<option value="belum" @selected($status==='belum')>Belum Hadir</option>
<option value="semua" @selected($status==='semua')>Semua</option>
</select>
</div>
<div class="col-md-3">
<select name="jabatan" class="form-select">
<option value="">Semua Jabatan</option>
@foreach($jabatanList as $j)
<option value="{{ $j }}" @selected($jabatan===$j)>{{ $j }}</option>
@endforeach
</select>
</div>
<div class="col-md-2"><button class="btn btn-teal w-100"><i class="bi bi-funnel"></i> Tapis</button></div>
</form>
<div class="table-responsive">
<table class="table table-koipb table-hover align-middle">
<thead><tr>
<th>#</th><th>Nama</th><th>No Pekerja</th><th>No KP</th><th>Jabatan / Bahagian</th><th>Status</th><th>Masa Hadir</th>
</tr></thead>
<tbody>
@forelse($members as $m)
<tr>
<td>{{ $loop->iteration + ($members->currentPage()-1)*$members->perPage() }}</td>
<td class="fw-semibold">{{ $m->nama }}</td>
<td>{{ $m->no_pekerja ?? '-' }}</td>
<td><small class="text-muted">{{ $m->maskedKp() ?? '-' }}</small></td>
<td>{{ $m->jabatan ?? '-' }} <span class="text-muted">/ {{ $m->bahagian ?? '-' }}</span></td>
<td>
@if($m->attendance)
<span class="badge badge-hadir">Hadir</span>
@else
<span class="badge badge-belum">Belum</span>
@endif
</td>
<td><small>{{ $m->attendance?->attended_at?->format('d/m/Y H:i:s') ?? '-' }}</small></td>
</tr>
@empty
<tr><td colspan="7" class="text-center text-muted py-4">Tiada rekod.</td></tr>
@endforelse
</tbody>
</table>
</div>
{{ $members->links() }}
</div>
</div>
@endsection

View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="ms">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log Masuk · Sistem MAT KOIPB</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="{{ asset('css/koipb.css') }}?v=1" rel="stylesheet">
</head>
<body>
<div class="login-wrap">
<div class="card login-card shadow-lg">
<div class="head">
<img src="{{ asset('images/logo-koipb.jpg') }}" alt="KOIPB" onerror="this.style.display='none'">
<h4 class="fw-bold mt-2 mb-0" style="color:var(--koipb-dark)">Koperasi Iskandar Puteri Berhad</h4>
<p class="text-muted small">Sistem Kehadiran & Cabutan Bertuah MAT</p>
</div>
<div class="card-body p-4">
@if($errors->any())
<div class="alert alert-danger py-2"><i class="bi bi-exclamation-circle"></i> {{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('login') }}">
@csrf
<div class="mb-3">
<label class="form-label fw-semibold">E-mel</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-envelope"></i></span>
<input type="email" name="email" class="form-control" value="{{ old('email') }}" required autofocus placeholder="admin@koipb.test">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Kata Laluan</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
<input type="password" name="password" class="form-control" required placeholder="••••••••">
</div>
</div>
<div class="form-check mb-3">
<input type="checkbox" name="remember" class="form-check-input" id="remember">
<label class="form-check-label" for="remember">Ingat saya</label>
</div>
<button class="btn btn-teal w-100 py-2 fw-bold"><i class="bi bi-box-arrow-in-right"></i> Log Masuk</button>
</form>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,105 @@
@extends('layouts.app')
@section('title', 'Kaunter Kehadiran')
@section('content')
<div class="row justify-content-center">
<div class="col-lg-9">
<div class="card mb-3">
<div class="card-body counter-search">
<label class="fw-bold mb-2 fs-5"><i class="bi bi-search text-teal"></i> Cari Anggota</label>
<input type="text" id="searchBox" class="form-control" autocomplete="off"
placeholder="Taip nama / no. KP / no. pekerja..." autofocus>
<div class="form-text">Carian automatik. Boleh guna nama, nombor kad pengenalan atau nombor pekerja MBIP.</div>
</div>
</div>
<div id="searchStatus" class="text-center text-muted py-2"></div>
<div id="resultArea"></div>
</div>
</div>
@push('scripts')
<script>
const searchUrl = "{{ route('counter.search') }}";
const storeUrl = "{{ route('counter.store') }}";
let timer = null, lastTerm = '';
const $box = $('#searchBox'), $status = $('#searchStatus'), $area = $('#resultArea');
$box.on('input', function(){
const term = this.value.trim();
clearTimeout(timer);
if (term.length < 2) { $area.empty(); $status.text(''); return; }
timer = setTimeout(() => doSearch(term), 220);
});
function doSearch(term){
lastTerm = term;
$status.html('<span class="spinner-border spinner-border-sm text-teal"></span> Mencari...');
$.getJSON(searchUrl, { q: term })
.done(res => { if (term !== lastTerm) return; render(res); })
.fail(() => $status.html('<span class="text-danger">Ralat carian.</span>'));
}
function render(res){
$area.empty();
if (!res.data || res.data.length === 0){
$status.html('');
$area.html(`<div class="alert alert-danger d-flex align-items-center gap-2 fs-6">
<i class="bi bi-x-octagon-fill fs-3"></i>
<div><b>Rekod tidak dijumpai.</b><br>Individu ini bukan anggota koperasi yang berdaftar.</div></div>`);
return;
}
$status.html(`<small>${res.data.length} rekod dijumpai</small>`);
res.data.forEach(m => $area.append(cardHtml(m)));
}
function cardHtml(m){
const hadirBadge = m.hadir
? `<span class="badge badge-hadir"><i class="bi bi-check-circle"></i> Sudah Hadir</span>`
: `<span class="badge badge-belum">Belum Hadir</span>`;
const timeInfo = m.hadir ? `<div class="small text-success mt-1"><i class="bi bi-clock"></i> Masa hadir: ${m.attended_at ?? '-'}</div>` : '';
const aktif = m.status_aktif ? '' : `<span class="badge bg-warning text-dark ms-1">Tidak Aktif</span>`;
const btn = m.hadir
? `<button class="btn btn-success" disabled><i class="bi bi-check2-all"></i> Telah Hadir</button>`
: `<button class="btn btn-teal btn-lg px-4" onclick="recordAttend(${m.id}, this)"><i class="bi bi-person-check"></i> Rekod Hadir</button>`;
return `<div class="card result-card mb-2 ${m.hadir ? 'hadir':''}" id="m-${m.id}">
<div class="card-body d-flex flex-wrap justify-content-between align-items-center gap-3">
<div>
<div class="fs-5 fw-bold">${escapeHtml(m.nama)} ${aktif}</div>
<div class="text-muted small">
<i class="bi bi-person-badge"></i> ${m.no_pekerja ?? '-'} &nbsp;·&nbsp;
<i class="bi bi-credit-card-2-front"></i> ${m.masked_kp ?? '-'} &nbsp;·&nbsp;
<i class="bi bi-building"></i> ${escapeHtml(m.jabatan ?? '-')} / ${escapeHtml(m.bahagian ?? '-')}
</div>
<div class="mt-1">${hadirBadge}</div>
${timeInfo}
</div>
<div class="text-end" id="act-${m.id}">${btn}</div>
</div></div>`;
}
function recordAttend(id, btn){
$(btn).prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Menyimpan...');
$.post(storeUrl, { member_id: id })
.done(res => {
const m = res.member;
$(`#m-${id}`).addClass('hadir');
$(`#act-${id}`).html('<button class="btn btn-success" disabled><i class="bi bi-check2-all"></i> Telah Hadir</button>');
if (res.status === 'ok'){
Swal.fire({ icon:'success', title:'Kehadiran Direkod!', text: res.message, timer: 1600, showConfirmButton:false });
} else {
Swal.fire({ icon:'info', title:'Sudah Hadir', text: res.message });
}
})
.fail(xhr => {
$(btn).prop('disabled', false).html('<i class="bi bi-person-check"></i> Rekod Hadir');
Swal.fire({ icon:'error', title:'Ralat', text: (xhr.responseJSON?.message) || 'Gagal merekod kehadiran.' });
});
}
function escapeHtml(s){ return (s ?? '').toString().replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
</script>
@endpush
@endsection

View File

@@ -0,0 +1,70 @@
@extends('layouts.app')
@section('title', 'Dashboard')
@section('content')
@php
$cards = [
['Jumlah Anggota', $stats['total_members'], 'bg-teal', 'bi-people-fill'],
['Jumlah Hadir', $stats['hadir'], 'bg-green', 'bi-person-check-fill'],
['Belum Hadir', $stats['belum_hadir'], 'bg-dark2', 'bi-person-dash-fill'],
['Jumlah Hadiah', $stats['total_prizes'], 'bg-cyan', 'bi-gift-fill'],
['Hadiah Belum Dicabut', $stats['prizes_belum'], 'bg-gold', 'bi-hourglass-split'],
['Hadiah Sudah Dicabut', $stats['prizes_disahkan'], 'bg-teal', 'bi-check2-circle'],
['Pemenang Disahkan', $stats['pemenang_disahkan'], 'bg-green', 'bi-trophy-fill'],
['Cabutan Dibatalkan', $stats['cabutan_dibatalkan'], 'bg-red', 'bi-x-octagon-fill'],
];
@endphp
<div class="row g-3 mb-4">
@foreach($cards as [$label, $value, $bg, $icon])
<div class="col-6 col-lg-3">
<div class="stat-card {{ $bg }}">
<i class="bi {{ $icon }} ico"></i>
<div class="label">{{ $label }}</div>
<div class="value">{{ number_format($value) }}</div>
</div>
</div>
@endforeach
</div>
<div class="row g-3">
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header bg-white fw-bold"><i class="bi bi-graph-up-arrow text-teal"></i> Kemajuan Event</div>
<div class="card-body">
@php
$hadirPct = $stats['total_members'] ? round($stats['hadir'] / $stats['total_members'] * 100) : 0;
$cabutPct = $stats['total_prizes'] ? round($stats['prizes_disahkan'] / $stats['total_prizes'] * 100) : 0;
@endphp
<label class="small fw-semibold">Kehadiran ({{ $stats['hadir'] }}/{{ $stats['total_members'] }})</label>
<div class="progress mb-3" style="height:22px;">
<div class="progress-bar bg-success fw-bold" style="width: {{ $hadirPct }}%">{{ $hadirPct }}%</div>
</div>
<label class="small fw-semibold">Hadiah Disahkan ({{ $stats['prizes_disahkan'] }}/{{ $stats['total_prizes'] }})</label>
<div class="progress" style="height:22px;">
<div class="progress-bar fw-bold" style="width: {{ $cabutPct }}%; background:var(--koipb-teal)">{{ $cabutPct }}%</div>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header bg-white fw-bold"><i class="bi bi-trophy text-teal"></i> Pemenang Terkini</div>
<div class="card-body p-0">
@forelse($recentWinners as $w)
<div class="d-flex align-items-center gap-3 px-3 py-2 border-bottom">
<span class="badge bg-gold rounded-pill">#{{ $w->prize->draw_order }}</span>
<div class="flex-grow-1">
<div class="fw-semibold">{{ $w->member->nama }}</div>
<div class="small text-muted">{{ $w->prize->nama_hadiah }}</div>
</div>
<i class="bi bi-trophy-fill" style="color:var(--koipb-gold)"></i>
</div>
@empty
<div class="text-center text-muted py-4"><i class="bi bi-hourglass"></i> Belum ada pemenang disahkan.</div>
@endforelse
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,334 @@
<!DOCTYPE html>
<html lang="ms">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Cabutan Bertuah · KOIPB MAT</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="{{ asset('css/koipb.css') }}?v=1" rel="stylesheet">
</head>
<body class="projector">
<div class="draw-topbar">
<div class="d-flex align-items-center gap-3">
<img src="{{ asset('images/logo-koipb.jpg') }}" onerror="this.style.display='none'">
<div>
<div class="fw-bold fs-5">Cabutan Bertuah Mesyuarat Agung Tahunan</div>
<div class="small" style="color:var(--koipb-teal-light)">{{ $session->nama }}</div>
</div>
</div>
<div class="d-flex align-items-center gap-3">
<span class="badge bg-light text-dark fs-6"><i class="bi bi-people-fill text-teal"></i> Peserta Layak: <b id="eligibleCount">{{ $eligibleCount }}</b></span>
<a href="{{ route('dashboard') }}" class="btn btn-outline-light btn-sm"><i class="bi bi-grid"></i> Dashboard</a>
</div>
</div>
<div class="draw-grid">
<!-- KIRI: WHEEL -->
<div class="wheel-stage">
<div class="mb-3 w-100 text-center" style="max-width:560px;">
<div class="text-uppercase small text-white-50" style="letter-spacing:2px;">Nombor Cabutan</div>
<div class="d-flex align-items-center justify-content-center gap-3">
<button id="prevPrize" class="btn btn-outline-light btn-sm rounded-circle" title="Hadiah sebelum" style="width:38px;height:38px;"><i class="bi bi-chevron-up"></i></button>
<div id="currentNumber" style="font-size:3.4rem;font-weight:900;line-height:1;color:var(--koipb-gold);"></div>
<div class="text-white-50 fs-5">/ <span id="totalNumber">{{ $startNumber }}</span></div>
<button id="nextPrize" class="btn btn-outline-light btn-sm rounded-circle" title="Hadiah seterusnya" style="width:38px;height:38px;"><i class="bi bi-chevron-down"></i></button>
</div>
<div id="prizeNameTop" class="prize-title mt-1"></div>
<div id="doneNote" class="badge bg-success fs-6 mt-2" style="display:none"><i class="bi bi-check2-all"></i> Semua cabutan selesai</div>
</div>
<div class="wheel-pointer"><i class="bi bi-caret-down-fill"></i></div>
<canvas id="wheelCanvas" width="500" height="500"></canvas>
<button id="spinBtn" class="btn-spin mt-4" disabled><i class="bi bi-arrow-repeat"></i> SPIN</button>
<div class="small mt-2 text-white-50" id="poolNote"></div>
</div>
<!-- KANAN: PANEL KEPUTUSAN -->
<div class="panel-result" id="resultPanel">
<div id="bigMsg" class="text-center mb-3" style="display:none"></div>
<div class="text-uppercase small text-white-50">Nombor Cabutan</div>
<div class="d-flex align-items-baseline gap-3">
<div class="display-5 fw-bold" id="prizeOrder"></div>
<div class="prize-title" id="prizeName">Belum dipilih</div>
</div>
<hr class="border-light opacity-25">
<div id="winnerBox" class="flex-grow-1 d-flex flex-column justify-content-center text-center" style="opacity:.4">
<div class="text-white-50">Calon Pemenang</div>
<div class="winner-name my-2" id="winnerName"></div>
<div id="winnerMeta" class="fs-5 text-white-50"></div>
</div>
<div class="text-center my-3">
<span class="pill" id="statusPill" style="display:none"></span>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-center" id="actionBtns">
<button id="confirmBtn" class="btn btn-success btn-lg px-4" style="display:none"><i class="bi bi-check-circle-fill"></i> Confirm Pemenang</button>
<button id="cancelBtn" class="btn btn-danger btn-lg px-4" style="display:none"><i class="bi bi-x-circle-fill"></i> Batal / Tiada Di Dewan</button>
<button id="redrawBtn" class="btn btn-warning btn-lg px-4" style="display:none"><i class="bi bi-arrow-repeat"></i> Cabut Semula</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script>
<script>
const urls = {
pool: "{{ route('draw.pool') }}",
spin: "{{ route('draw.spin') }}",
confirm: "{{ route('draw.confirm') }}",
cancel: "{{ route('draw.cancel') }}",
};
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content } });
// Senarai hadiah berserta Nombor Cabutan (seq), serta nombor mula (had kehadiran)
let PRIZES = {!! json_encode($prizes) !!};
const START_NUMBER = {{ $startNumber }};
const COLORS = ['#0a8f9c','#2b3036','#14b8c8','#e0a91b','#1e9e5a','#086e78','#1bb6c4','#3a4149'];
const canvas = document.getElementById('wheelCanvas');
const ctx = canvas.getContext('2d');
let pool = [], rotation = 0, spinning = false, currentResultId = null, currentPrizeId = null, currentSeq = null;
function loadPool(cb){
$.getJSON(urls.pool).done(res => { pool = res.pool || []; $('#poolNote').text(pool.length + ' peserta dalam pool'); drawWheel(); cb && cb(); });
}
function drawWheel(){
const n = Math.max(pool.length, 1);
const cx = 250, cy = 250, r = 240;
const seg = (2*Math.PI)/n;
ctx.clearRect(0,0,500,500);
ctx.save(); ctx.translate(cx,cy); ctx.rotate(rotation);
const showLabels = pool.length > 0 && pool.length <= 40;
for (let i=0;i<n;i++){
ctx.beginPath(); ctx.moveTo(0,0);
ctx.arc(0,0,r, i*seg, (i+1)*seg);
ctx.closePath();
ctx.fillStyle = COLORS[i % COLORS.length];
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.15)'; ctx.stroke();
if (showLabels && pool[i]){
ctx.save();
ctx.rotate(i*seg + seg/2);
ctx.textAlign='right'; ctx.fillStyle='#fff'; ctx.font='bold 13px Segoe UI';
const lbl = pool[i].label || pool[i].nama || '';
ctx.fillText(lbl.substring(0,16), r-14, 5);
ctx.restore();
}
}
ctx.restore();
// hub
ctx.beginPath(); ctx.arc(cx,cy,38,0,2*Math.PI); ctx.fillStyle='#fff'; ctx.fill();
ctx.fillStyle='#0a8f9c'; ctx.font='bold 30px Segoe UI'; ctx.textAlign='center';
ctx.fillText('★', cx, cy+11);
if (!showLabels && pool.length>0){
ctx.fillStyle='#fff'; ctx.font='bold 18px Segoe UI';
ctx.fillText(pool.length + ' peserta', cx, cy+70);
}
}
function animateTo(winnerIndex, done){
const n = pool.length; if (n===0){ done(); return; }
const seg = (2*Math.PI)/n;
// sudut supaya tengah segmen winner berada di atas (pointer pada -90°)
const target = (-Math.PI/2) - (winnerIndex*seg + seg/2);
const turns = 6 + Math.floor(Math.random()*3);
const finalRot = target + turns*2*Math.PI;
const startRot = rotation % (2*Math.PI);
const duration = 5200, t0 = performance.now();
function frame(now){
let p = Math.min((now-t0)/duration, 1);
const ease = 1 - Math.pow(1-p, 3); // ease-out cubic
rotation = startRot + (finalRot - startRot) * ease;
drawWheel();
if (p < 1) requestAnimationFrame(frame); else { spinning=false; done(); }
}
requestAnimationFrame(frame);
}
// ---- Nombor Cabutan: cabutan menurun dari nombor terakhir (had = kehadiran) ----
function prizeBySeq(seq){ return PRIZES.find(p => p.seq === seq) || null; }
// Hadiah pertama yang boleh dicabut (seq tertinggi <= START_NUMBER yang belum disahkan)
function firstDrawable(){
for (let s = START_NUMBER; s >= 1; s--){
const p = prizeBySeq(s);
if (p && p.status !== 'disahkan') return s;
}
return 0;
}
// Hadiah seterusnya (menurun) yang masih boleh dicabut, bermula bawah daripada 'from'
function nextDrawableDown(from){
for (let s = from; s >= 1; s--){
const p = prizeBySeq(s);
if (p && p.status !== 'disahkan') return s;
}
return 0;
}
function prevDrawableUp(from){
for (let s = from; s <= START_NUMBER; s++){
const p = prizeBySeq(s);
if (p && p.status !== 'disahkan') return s;
}
return 0;
}
function gotoSeq(seq){
resetPanel();
currentSeq = seq;
const p = seq ? prizeBySeq(seq) : null;
if (!p){
currentPrizeId = null;
$('#currentNumber').text('—');
$('#spinBtn').prop('disabled', true);
$('#prevPrize,#nextPrize').prop('disabled', true);
if (START_NUMBER === 0){
$('#prizeNameTop').text('Tiada kehadiran direkod');
$('#prizeOrder').text('—'); $('#prizeName').text('Tiada peserta hadir');
$('#doneNote').hide();
} else {
$('#prizeNameTop').text('Selesai');
$('#prizeOrder').text('—'); $('#prizeName').text('Selesai');
$('#doneNote').show();
}
return;
}
$('#doneNote').hide();
currentPrizeId = p.id;
$('#currentNumber').text(seq);
$('#prizeNameTop').text(p.nama_hadiah);
$('#prizeOrder').text('No. ' + seq);
$('#prizeName').text(p.nama_hadiah);
$('#spinBtn').prop('disabled', false);
// butang naik/turun
$('#prevPrize').prop('disabled', prevDrawableUp(seq + 1) === 0);
$('#nextPrize').prop('disabled', nextDrawableDown(seq - 1) === 0);
}
$('#nextPrize').on('click', () => { if (spinning) return; const s = nextDrawableDown(currentSeq - 1); if (s) gotoSeq(s); });
$('#prevPrize').on('click', () => { if (spinning) return; const s = prevDrawableUp(currentSeq + 1); if (s) gotoSeq(s); });
function resetPanel(){
$('#resultPanel').removeClass('win cancel');
$('#bigMsg').hide().empty();
$('#statusPill').hide();
$('#confirmBtn,#cancelBtn,#redrawBtn').hide();
$('#winnerBox').css('opacity', .4);
$('#winnerName').text('—'); $('#winnerMeta').text('');
currentResultId = null;
}
function lockUi(lock){
$('#spinBtn,#prevPrize,#nextPrize,#confirmBtn,#cancelBtn,#redrawBtn').prop('disabled', lock);
}
// ---- SPIN ----
function doSpin(){
if (spinning || !currentPrizeId) return;
spinning = true; lockUi(true); resetPanel();
$('#spinBtn').html('<span class="spinner-border spinner-border-sm"></span> Memusing...');
$.post(urls.spin, { prize_id: currentPrizeId })
.done(res => {
pool = res.pool;
animateTo(res.winner_index, () => revealWinner(res));
})
.fail(xhr => {
spinning=false; lockUi(false); $('#spinBtn').html('<i class="bi bi-arrow-repeat"></i> SPIN');
Swal.fire({icon:'warning', title:'Tidak boleh spin', text: xhr.responseJSON?.message || 'Ralat.'});
});
}
function revealWinner(res){
currentResultId = res.result_id;
const w = res.winner;
$('#winnerBox').css('opacity', 1);
$('#winnerName').text(w.nama);
$('#winnerMeta').html(`${w.no_pekerja ?? ''} · ${w.jabatan ?? '-'}<br><small>${w.masked_kp ?? ''}</small>`);
$('#statusPill').attr('class','pill pill-sedang').text('Menunggu Pengesahan').show();
$('#spinBtn').html('<i class="bi bi-arrow-repeat"></i> SPIN').prop('disabled', true);
$('#prevPrize,#nextPrize').prop('disabled', true);
$('#confirmBtn,#cancelBtn').show().prop('disabled', false);
$('#redrawBtn').hide();
}
// ---- CONFIRM ----
$('#confirmBtn').on('click', function(){
lockUi(true);
$.post(urls.confirm, { result_id: currentResultId })
.done(res => {
$('#resultPanel').addClass('win').removeClass('cancel');
$('#statusPill').attr('class','pill pill-disahkan').text('DISAHKAN ✓').show();
$('#bigMsg').html('<div class="big-msg" style="color:var(--koipb-gold)">🎉 TAHNIAH! 🎉</div>').show();
$('#confirmBtn,#cancelBtn,#redrawBtn').hide();
fireConfetti();
markPrizeDone(currentPrizeId);
// Auto-turun ke nombor cabutan seterusnya selepas 2.4 saat
const nextSeq = nextDrawableDown(currentSeq - 1);
setTimeout(() => loadPool(() => gotoSeq(nextSeq)), 2400);
})
.fail(xhr => { lockUi(false); Swal.fire({icon:'error', title:'Gagal sahkan', text: xhr.responseJSON?.message || 'Ralat.'}); });
});
// ---- CANCEL ----
$('#cancelBtn').on('click', function(){
Swal.fire({
icon:'warning', title:'Batalkan cabutan?', text:'Pemenang tiada di dewan. Hadiah akan dicabut semula.',
showCancelButton:true, confirmButtonText:'Ya, Batalkan', cancelButtonText:'Tidak', confirmButtonColor:'#c1272d',
input:'text', inputPlaceholder:'Sebab (pilihan)'
}).then(r => {
if (!r.isConfirmed) return;
lockUi(true);
$.post(urls.cancel, { result_id: currentResultId, sebab: r.value || '' })
.done(res => {
$('#resultPanel').addClass('cancel').removeClass('win');
$('#statusPill').attr('class','pill pill-redraw').text('DIBATALKAN').show();
$('#bigMsg').html('<div class="big-msg" style="color:#ff8a8a;font-size:2rem">Cabutan Dibatalkan<br><small style="font-size:1.1rem">Pemenang Tiada Di Dewan</small></div>').show();
$('#confirmBtn,#cancelBtn').hide();
$('#redrawBtn').show().prop('disabled', false);
loadPool();
})
.fail(xhr => { lockUi(false); Swal.fire({icon:'error', title:'Gagal', text: xhr.responseJSON?.message || 'Ralat.'}); });
});
});
// ---- REDRAW (cabut semula hadiah sama) ----
$('#redrawBtn').on('click', () => doSpin());
$('#spinBtn').on('click', () => doSpin());
function markPrizeDone(prizeId){
const p = PRIZES.find(x => x.id === prizeId);
if (p) p.status = 'disahkan';
}
function fireConfetti(){
const end = Date.now() + 1500;
(function frame(){
confetti({ particleCount: 6, angle: 60, spread: 70, origin: { x: 0 }, colors:['#0a8f9c','#e0a91b','#1e9e5a','#fff'] });
confetti({ particleCount: 6, angle: 120, spread: 70, origin: { x: 1 }, colors:['#0a8f9c','#e0a91b','#1e9e5a','#fff'] });
if (Date.now() < end) requestAnimationFrame(frame);
})();
confetti({ particleCount: 160, spread: 100, origin: { y: 0.6 } });
}
// ---- Mula: pilih nombor cabutan tertinggi yang boleh dicabut ----
@if($pending)
@php $pendingWinner = ['nama' => $pending->member->nama, 'no_pekerja' => $pending->member->no_pekerja, 'jabatan' => $pending->member->jabatan, 'masked_kp' => $pending->member->maskedKp()]; @endphp
loadPool(() => {
const pendingPrize = PRIZES.find(p => p.id === {{ $pending->prize_id }});
gotoSeq(pendingPrize ? pendingPrize.seq : firstDrawable());
revealWinner({ result_id: {{ $pending->id }}, winner: {!! json_encode($pendingWinner) !!} });
});
@else
loadPool(() => gotoSeq(firstDrawable()));
@endif
</script>
</body>
</html>

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