first
This commit is contained in:
10
.claude/settings.json
Normal file
10
.claude/settings.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(php -v)",
|
||||||
|
"Bash(composer -V)",
|
||||||
|
"Bash(npm -v)",
|
||||||
|
"Bash(mysql --version)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
18
.editorconfig
Normal 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
46
.env.docker.example
Normal 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
65
.env.example
Normal 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
11
.gitattributes
vendored
Normal 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
27
.gitignore
vendored
Normal 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
|
||||||
178
DEPLOYMENT.md
Normal file
178
DEPLOYMENT.md
Normal 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 150–200 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
60
Dockerfile
Normal 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
143
README.md
Normal 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: 150–200 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.)**
|
||||||
26
app/Http/Controllers/Admin/AuditController.php
Normal file
26
app/Http/Controllers/Admin/AuditController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/Http/Controllers/Admin/MemberController.php
Normal file
91
app/Http/Controllers/Admin/MemberController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Http/Controllers/Admin/PrizeController.php
Normal file
83
app/Http/Controllers/Admin/PrizeController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Http/Controllers/Admin/SettingController.php
Normal file
60
app/Http/Controllers/Admin/SettingController.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Http/Controllers/AttendanceController.php
Normal file
42
app/Http/Controllers/AttendanceController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Controllers/Auth/LoginController.php
Normal file
47
app/Http/Controllers/Auth/LoginController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
109
app/Http/Controllers/CounterController.php
Normal file
109
app/Http/Controllers/CounterController.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Http/Controllers/DashboardController.php
Normal file
37
app/Http/Controllers/DashboardController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
173
app/Http/Controllers/DrawController.php
Normal file
173
app/Http/Controllers/DrawController.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/Http/Controllers/ReportController.php
Normal file
119
app/Http/Controllers/ReportController.php
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Models/AttendanceRecord.php
Normal file
34
app/Models/AttendanceRecord.php
Normal 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
52
app/Models/AuditLog.php
Normal 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
83
app/Models/DrawResult.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Models/DrawSession.php
Normal file
48
app/Models/DrawSession.php
Normal 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
32
app/Models/ImportLog.php
Normal 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
110
app/Models/Member.php
Normal 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
91
app/Models/Prize.php
Normal 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
38
app/Models/User.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal 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
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
180
app/Services/DrawService.php
Normal file
180
app/Services/DrawService.php
Normal 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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app/Services/MemberImportService.php
Normal file
117
app/Services/MemberImportService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Services/PrizeImportService.php
Normal file
92
app/Services/PrizeImportService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/Support/Spreadsheet.php
Normal file
93
app/Support/Spreadsheet.php
Normal 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
18
artisan
Normal 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
27
bootstrap/app.php
Normal 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
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
7
bootstrap/providers.php
Normal file
7
bootstrap/providers.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
|
||||||
|
return [
|
||||||
|
AppServiceProvider::class,
|
||||||
|
];
|
||||||
89
composer.json
Normal file
89
composer.json
Normal 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
9058
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal 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
117
config/auth.php
Normal 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
136
config/cache.php
Normal 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
184
config/database.php
Normal 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
80
config/filesystems.php
Normal 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
132
config/logging.php
Normal 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
118
config/mail.php
Normal 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
219
config/permission.php
Normal 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
129
config/queue.php
Normal 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
38
config/services.php
Normal 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
233
config/session.php
Normal 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
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite*
|
||||||
46
database/factories/MemberFactory.php
Normal file
46
database/factories/MemberFactory.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
45
database/factories/UserFactory.php
Normal file
45
database/factories/UserFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
59
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
59
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
};
|
||||||
65
database/seeders/DatabaseSeeder.php
Normal file
65
database/seeders/DatabaseSeeder.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
database/seeders/RoleUserSeeder.php
Normal file
38
database/seeders/RoleUserSeeder.php
Normal 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
47
docker-compose.prod.yml
Normal 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
89
docker-compose.yml
Normal 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
105
docker/entrypoint.sh
Normal 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
75
docker/nginx/default.conf
Normal 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
13
docker/php/php-dev.ini
Normal 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
11
docker/php/php-prod.ini
Normal 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
45
docker/php/php.ini
Normal 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
16
package.json
Normal 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
36
phpunit.xml
Normal 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
25
public/.htaccess
Normal 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
137
public/css/koipb.css
Normal 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
0
public/favicon.ico
Normal file
BIN
public/images/logo-koipb.jpg
Normal file
BIN
public/images/logo-koipb.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
20
public/index.php
Normal file
20
public/index.php
Normal 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
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
6
public/samples/contoh-anggota.csv
Normal file
6
public/samples/contoh-anggota.csv
Normal 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
|
||||||
|
7
public/samples/contoh-hadiah.csv
Normal file
7
public/samples/contoh-hadiah.csv
Normal 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
|
||||||
|
9
resources/css/app.css
Normal file
9
resources/css/app.css
Normal 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
1
resources/js/app.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//
|
||||||
36
resources/views/admin/audit/index.blade.php
Normal file
36
resources/views/admin/audit/index.blade.php
Normal 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
|
||||||
40
resources/views/admin/members/edit.blade.php
Normal file
40
resources/views/admin/members/edit.blade.php
Normal 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
|
||||||
55
resources/views/admin/members/import.blade.php
Normal file
55
resources/views/admin/members/import.blade.php
Normal 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
|
||||||
49
resources/views/admin/members/index.blade.php
Normal file
49
resources/views/admin/members/index.blade.php
Normal 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
|
||||||
29
resources/views/admin/prizes/edit.blade.php
Normal file
29
resources/views/admin/prizes/edit.blade.php
Normal 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
|
||||||
51
resources/views/admin/prizes/import.blade.php
Normal file
51
resources/views/admin/prizes/import.blade.php
Normal 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 > 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
|
||||||
42
resources/views/admin/prizes/index.blade.php
Normal file
42
resources/views/admin/prizes/index.blade.php
Normal 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
|
||||||
40
resources/views/admin/settings/index.blade.php
Normal file
40
resources/views/admin/settings/index.blade.php
Normal 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
|
||||||
69
resources/views/attendance/index.blade.php
Normal file
69
resources/views/attendance/index.blade.php
Normal 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
|
||||||
49
resources/views/auth/login.blade.php
Normal file
49
resources/views/auth/login.blade.php
Normal 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>
|
||||||
105
resources/views/counter/index.blade.php
Normal file
105
resources/views/counter/index.blade.php
Normal 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 ?? '-'} ·
|
||||||
|
<i class="bi bi-credit-card-2-front"></i> ${m.masked_kp ?? '-'} ·
|
||||||
|
<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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
@endsection
|
||||||
70
resources/views/dashboard.blade.php
Normal file
70
resources/views/dashboard.blade.php
Normal 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
|
||||||
334
resources/views/draw/index.blade.php
Normal file
334
resources/views/draw/index.blade.php
Normal 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
Reference in New Issue
Block a user