Initial commit
This commit is contained in:
42
.claude/settings.local.json
Normal file
42
.claude/settings.local.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go get *)",
|
||||
"Bash(go mod *)",
|
||||
"Bash(GOTOOLCHAIN=local go get *)",
|
||||
"Bash(brew info *)",
|
||||
"Bash(gunzip)",
|
||||
"Bash(tar -x -C /tmp/go123)",
|
||||
"Bash(mkdir -p /tmp/go123)",
|
||||
"Bash(gunzip -c Payload)",
|
||||
"Bash(/tmp/go123/usr/local/go/bin/go version *)",
|
||||
"Bash(export PATH=\"/tmp/go123/usr/local/go/bin:$PATH\")",
|
||||
"Bash(export GOROOT=\"/tmp/go123/usr/local/go\")",
|
||||
"Bash(go build *)",
|
||||
"Bash(pkill -f \"ssh -f -N -L 3307\")",
|
||||
"Bash(ssh *)",
|
||||
"Bash(go run *)",
|
||||
"Bash(curl -s \"http://localhost:8080/dashboard?date=2024-09-26\")",
|
||||
"Bash(curl -s \"http://localhost:8080/dashboard/kpi?date=2024-09-26\")",
|
||||
"Bash(curl -s \"http://localhost:8080/dashboard/stations?date=2024-09-26\")",
|
||||
"Bash(sed -i '' 's/:3306/:3307/' /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard/.env)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" \"http://localhost:8080/dashboard?date=2024-09-26\")",
|
||||
"Bash(pkill -f \"go run .\")",
|
||||
"Bash(curl -s \"http://localhost:8080/\" -o /dev/null -w \"%{http_code}\\\\n\")",
|
||||
"Bash(curl -s \"http://localhost:8080/dashboard/\" -o /dev/null -w \"%{http_code}\\\\n\")",
|
||||
"Read(//private/tmp/**)",
|
||||
"Bash(curl -s \"http://localhost:8080/\" -o /dev/null -w \"root: %{http_code}\\\\n\")",
|
||||
"Bash(curl -s -N --max-time 3 \"http://localhost:8080/dashboard/stream?date=2024-09-26\")",
|
||||
"Bash(curl -s -N --max-time 4 -H \"Accept: text/event-stream\" \"http://localhost:8080/dashboard/stream?date=2024-09-26\")",
|
||||
"Bash(pkill -f \"__debug_bin\")",
|
||||
"Bash(lsof -ti:8080)",
|
||||
"Bash(xargs kill *)",
|
||||
"Bash(curl -s -N --max-time 5 \"http://localhost:8080/dashboard/stream?date=2024-09-26\")",
|
||||
"Bash(mysql *)",
|
||||
"Bash(mkdir -p /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard/static/img)",
|
||||
"Bash(cp /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard/assets/logo.png /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard/static/img/logo.png)",
|
||||
"Bash(lsof -nP -iTCP:3307 -sTCP:LISTEN)",
|
||||
"Bash(lsof -nP -iTCP:8080 -sTCP:LISTEN)"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
PLAN/draft-cpone
Submodule
1
PLAN/draft-cpone
Submodule
Submodule PLAN/draft-cpone added at 9069dc0613
5
cpone-dashboard/.env.example
Normal file
5
cpone-dashboard/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
APP_PORT=8080
|
||||
DB_DSN=user:password@tcp(127.0.0.1:3306)/cpone_dashboard?parseTime=true&loc=Local
|
||||
AUTH_SECRET=random-32-char-string-ganti-ini
|
||||
PDF_BASE_URL=http://your-server/dashboard-files/
|
||||
BASE_PATH= # kosong = root, isi "/cpone-dashboard" untuk sub-path
|
||||
3
cpone-dashboard/.gitignore
vendored
Normal file
3
cpone-dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
cpone-dashboard
|
||||
cpone-dashboard-linux
|
||||
337
cpone-dashboard/DEPLOY.md
Normal file
337
cpone-dashboard/DEPLOY.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Deployment Guide — cpone-dashboard
|
||||
|
||||
Dokumen ini mencatat semua yang dilakukan dari awal sampai app berjalan di server, lengkap dengan langkah untuk deploy ulang atau deploy ke server baru.
|
||||
|
||||
---
|
||||
|
||||
## Ringkasan Yang Dikerjakan
|
||||
|
||||
### Fitur yang diimplementasi
|
||||
|
||||
| Fitur | File |
|
||||
|-------|------|
|
||||
| Result menu — list pasien + View PDF modal | `menu/result/query.go`, `menu/result/handler.go`, `templates/result/index.html` |
|
||||
| PDF base URL via env | `config/config.go`, `.env`, `main.go` |
|
||||
| BASE_PATH — akses via sub-path tanpa port | `config/config.go`, semua handler, semua template |
|
||||
| Login path `/mcu-login` (hindari konflik Apache) | `menu/auth/route.go`, `menu/auth/handler.go`, `menu/auth/middleware.go`, `templates/login/index.html` |
|
||||
| Demo data seed (1500 pasien MCU DEMO 2026) | `scripts/demo_seed.py` |
|
||||
| Live simulation script | `scripts/demo_live.sh` |
|
||||
| Deploy ke devcpone via systemd user service | `Makefile`, `.config/systemd/user/cpone-dashboard.service` |
|
||||
|
||||
### Perubahan kode
|
||||
|
||||
```
|
||||
config/config.go → tambah PDFBaseURL + BasePath field
|
||||
.env / .env.example → tambah PDF_BASE_URL + BASE_PATH
|
||||
main.go → b() template func, mount routes kondisional,
|
||||
SetBasePath ke semua package
|
||||
menu/auth/route.go → /login → /mcu-login
|
||||
menu/auth/handler.go → SetBasePath, redirect pakai basePath
|
||||
menu/auth/middleware.go → redirect ke basePath+/mcu-login
|
||||
menu/result/query.go → full implementation (ResultRow, GetResultRows, filter)
|
||||
menu/result/handler.go → full implementation (pageData, Index, SetPDFBaseURL, SetBasePath)
|
||||
menu/dashboard/handler.go → SetBasePath, redirect pakai basePath
|
||||
menu/arrival/handler.go → SetBasePath, redirect pakai basePath
|
||||
menu/progress/handler.go → SetBasePath, redirect pakai basePath
|
||||
menu/abnormal/handler.go → SetBasePath, redirect pakai basePath
|
||||
menu/projects/handler.go → SetBasePath, redirect pakai basePath
|
||||
templates/layout/base.html → semua nav link pakai {{b "/..."}}
|
||||
templates/login/index.html → src + action pakai {{b "/..."}}
|
||||
templates/auth/password.html → semua link pakai {{b "/..."}}
|
||||
templates/projects/index.html → semua link pakai {{b "/..."}}
|
||||
templates/dashboard/index.html → sse-connect, form action, fetch() pakai {{b "/..."}}
|
||||
templates/arrival/index.html → link + form action pakai {{b "/..."}}
|
||||
templates/progress/index.html → link + form action pakai {{b "/..."}}
|
||||
templates/abnormal/index.html → tab links pakai {{b "/..."}}
|
||||
templates/result/index.html → full template (4 section + PDF modal dialog)
|
||||
scripts/demo_seed.py → generate 1500 pasien demo + kelainan + published
|
||||
scripts/demo_live.sh → simulasi live MCU (checkin, station, validasi, publish)
|
||||
scripts/demo_cleanup.sql → hapus semua data demo
|
||||
scripts/README.md → panduan penggunaan demo scripts
|
||||
Makefile → update deploy target (systemd), tambah logs + status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Arsitektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ devcpone server │
|
||||
│ │
|
||||
│ cpone-dashboard (Go binary, port 8090) │
|
||||
│ │ │
|
||||
│ └── MySQL 3306 → cpone_dashboard (DB utama) │
|
||||
│ ├── mcu_project │
|
||||
│ ├── mcu_patient │
|
||||
│ ├── mcu_checkinout │
|
||||
│ ├── mcu_station_progress │
|
||||
│ ├── mcu_patient_resume_status │
|
||||
│ ├── published_mcu_dashboard_sync │
|
||||
│ ├── kelainan_details │
|
||||
│ └── ... (lihat db/migrations/) │
|
||||
│ │
|
||||
│ /home/one/project/one/dashboard-files/ ← file PDF │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
Di laptop (dev):
|
||||
DB diakses via SSH tunnel → localhost:3307 → devcpone:3306
|
||||
make start (buka tunnel + jalankan app lokal)
|
||||
```
|
||||
|
||||
**Tech stack:** Go 1.21, Chi router, HTML templates (embed), HTMX, Tailwind CDN, ECharts CDN, MySQL 8
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Di laptop (build)
|
||||
- Go 1.21+ (`/usr/local/go/bin/go`)
|
||||
- SSH access ke `one@devcpone.aplikasi.web.id`
|
||||
- Python 3 (untuk generate demo data)
|
||||
|
||||
### Di server tujuan
|
||||
- Ubuntu/Linux x86_64
|
||||
- MySQL 8 dengan database `cpone_dashboard` sudah ada dan migration sudah dijalankan
|
||||
- Systemd user service tersedia (`systemctl --user`)
|
||||
- User dengan akses ke MySQL (`admin:password@127.0.0.1:3306`)
|
||||
- Direktori untuk file PDF: `/path/to/dashboard-files/` bisa diakses via HTTP
|
||||
|
||||
---
|
||||
|
||||
## Deploy Pertama Kali (Server Baru)
|
||||
|
||||
### 1. Persiapan database
|
||||
|
||||
Pastikan database `cpone_dashboard` sudah ada dan semua migration dijalankan:
|
||||
|
||||
```bash
|
||||
ssh user@server "mysql -u admin -pPASSWORD -e 'SHOW DATABASES;'"
|
||||
```
|
||||
|
||||
Jalankan migration satu per satu jika belum:
|
||||
|
||||
```bash
|
||||
ssh user@server "mysql -u admin -pPASSWORD cpone_dashboard < /path/migration/001_init_schema.sql"
|
||||
# dst untuk 002 - 011
|
||||
```
|
||||
|
||||
Migration ada di: `db/migrations/001_init_schema.sql` sampai `011_patient_resume_status.sql`
|
||||
|
||||
### 2. Buat direktori deploy di server
|
||||
|
||||
```bash
|
||||
ssh user@server "mkdir -p /home/user/project/cpone-dashboard"
|
||||
```
|
||||
|
||||
### 3. Buat file .env di server
|
||||
|
||||
```bash
|
||||
ssh user@server "cat > /home/user/project/cpone-dashboard/.env << 'EOF'
|
||||
APP_PORT=8090
|
||||
DB_DSN=admin:PASSWORD@tcp(127.0.0.1:3306)/cpone_dashboard?parseTime=true&loc=Local
|
||||
AUTH_SECRET=ganti-dengan-string-random-32-karakter
|
||||
PDF_BASE_URL=http://domain-server/dashboard-files/
|
||||
EOF"
|
||||
```
|
||||
|
||||
Sesuaikan:
|
||||
- `APP_PORT` — port yang belum dipakai di server (cek dengan `ss -tlnp`)
|
||||
- `DB_DSN` — sesuaikan host, port, user, password, nama database
|
||||
- `AUTH_SECRET` — string random minimal 32 karakter, bisa generate: `openssl rand -hex 32`
|
||||
- `PDF_BASE_URL` — URL publik ke folder tempat file PDF disimpan
|
||||
|
||||
### 4. Buat systemd user service
|
||||
|
||||
```bash
|
||||
ssh user@server "mkdir -p ~/.config/systemd/user && cat > ~/.config/systemd/user/cpone-dashboard.service << 'EOF'
|
||||
[Unit]
|
||||
Description=CpOne Dashboard
|
||||
ConditionPathExists=/home/user/project/cpone-dashboard/cpone-dashboard
|
||||
After=network.target
|
||||
StartLimitIntervalSec=60
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
WorkingDirectory=/home/user/project/cpone-dashboard
|
||||
ExecStart=/home/user/project/cpone-dashboard/cpone-dashboard
|
||||
EnvironmentFile=/home/user/project/cpone-dashboard/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF"
|
||||
```
|
||||
|
||||
Ganti `/home/user` dengan home directory user yang dipakai.
|
||||
|
||||
### 5. Update Makefile untuk server baru
|
||||
|
||||
Edit bagian atas `Makefile`:
|
||||
|
||||
```makefile
|
||||
SERVER=user@server-baru.domain.com
|
||||
DEPLOY_DIR=/home/user/project/cpone-dashboard
|
||||
```
|
||||
|
||||
### 6. Build dan deploy
|
||||
|
||||
```bash
|
||||
cd /path/to/cpone-dashboard
|
||||
make deploy
|
||||
```
|
||||
|
||||
Perintah ini:
|
||||
1. Cross-compile binary untuk linux/amd64
|
||||
2. Upload ke server via SCP
|
||||
3. Restart systemd service
|
||||
|
||||
### 7. Enable service agar auto-start saat reboot
|
||||
|
||||
```bash
|
||||
ssh user@server "systemctl --user enable cpone-dashboard"
|
||||
```
|
||||
|
||||
### 8. Verifikasi
|
||||
|
||||
```bash
|
||||
make status # cek status service
|
||||
make logs # lihat log live
|
||||
|
||||
# Atau langsung dari browser:
|
||||
# http://server-domain:PORT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploy Ulang (Update Kode)
|
||||
|
||||
Cukup jalankan:
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
make deploy
|
||||
```
|
||||
|
||||
Itu saja. Build → upload → restart otomatis.
|
||||
|
||||
---
|
||||
|
||||
## Development Lokal
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
|
||||
# Jalankan (buka SSH tunnel ke devcpone:3306 → localhost:3307, lalu start app)
|
||||
make start
|
||||
|
||||
# Stop (tutup tunnel + kill app)
|
||||
make stop
|
||||
|
||||
# Akses di browser: http://localhost:8080
|
||||
```
|
||||
|
||||
File `.env` lokal menggunakan:
|
||||
```
|
||||
DB_DSN=...@tcp(127.0.0.1:3307)/cpone_dashboard... ← via tunnel port 3307
|
||||
APP_PORT=8080
|
||||
PDF_BASE_URL=http://devcpone.aplikasi.web.id/dashboard-files/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Konfigurasi .env
|
||||
|
||||
| Key | Keterangan | Contoh |
|
||||
|-----|-----------|--------|
|
||||
| `APP_PORT` | Port HTTP server | `8090` |
|
||||
| `DB_DSN` | MySQL DSN (Go format) | `user:pass@tcp(host:3306)/dbname?parseTime=true&loc=Local` |
|
||||
| `AUTH_SECRET` | Secret untuk session cookie JWT | string random 32+ karakter |
|
||||
| `PDF_BASE_URL` | Base URL untuk file PDF resume individu | `http://domain/dashboard-files/` |
|
||||
|
||||
`PDF_BASE_URL` harus diakhiri `/`. File PDF disimpan di server dengan path relatif misal `2026/04/R2604xxxx_resume_individu.pdf`, sehingga full URL = `PDF_BASE_URL + relative_path`.
|
||||
|
||||
---
|
||||
|
||||
## Struktur File di Server
|
||||
|
||||
```
|
||||
/home/one/project/cpone-dashboard/
|
||||
├── cpone-dashboard ← binary (hasil build)
|
||||
└── .env ← konfigurasi (jangan commit ke git)
|
||||
|
||||
/home/one/.config/systemd/user/
|
||||
└── cpone-dashboard.service ← systemd unit file
|
||||
|
||||
/home/one/project/one/dashboard-files/
|
||||
└── 2026/
|
||||
└── 04/
|
||||
└── R2604xxxx_resume_individu.pdf ← file PDF hasil MCU
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Service gagal start:**
|
||||
```bash
|
||||
journalctl --user -u cpone-dashboard -n 30 --no-pager
|
||||
```
|
||||
|
||||
**Port sudah dipakai:**
|
||||
```bash
|
||||
ss -tlnp | grep PORT
|
||||
# Ganti APP_PORT di .env, lalu restart
|
||||
systemctl --user restart cpone-dashboard
|
||||
```
|
||||
|
||||
**Terlalu banyak restart (failed state):**
|
||||
```bash
|
||||
systemctl --user stop cpone-dashboard
|
||||
systemctl --user reset-failed cpone-dashboard
|
||||
systemctl --user start cpone-dashboard
|
||||
```
|
||||
|
||||
**Database tidak bisa connect:**
|
||||
- Pastikan `DB_DSN` di `.env` benar
|
||||
- Cek MySQL berjalan: `mysql -u admin -pPASS -e "SELECT 1;"`
|
||||
- Kalau pakai tunnel (dev lokal): pastikan tunnel aktif
|
||||
|
||||
**Binary tidak update:**
|
||||
```bash
|
||||
# Cek binary sudah ter-upload
|
||||
ssh one@devcpone.aplikasi.web.id "ls -lh /home/one/project/cpone-dashboard/cpone-dashboard"
|
||||
# Force build ulang
|
||||
cd /path/to/repo && make deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Demo Data (Opsional)
|
||||
|
||||
Untuk demo ke client dengan data realistis (MCU PROJECT DEMO 2026, 1500 pasien):
|
||||
|
||||
```bash
|
||||
# Seed data
|
||||
python3 scripts/demo_seed.py | ssh one@devcpone.aplikasi.web.id \
|
||||
"mysql -u admin -pSasone\!102938 cpone_dashboard"
|
||||
|
||||
# Buat PDF demo di server
|
||||
ssh one@devcpone.aplikasi.web.id "
|
||||
mkdir -p /home/one/project/one/dashboard-files/2026/04
|
||||
SRC=/home/one/project/one/dashboard-files/2024/09/R2409170003_resume_individu.pdf
|
||||
for i in \$(seq 1 80); do
|
||||
cp \"\$SRC\" \"/home/one/project/one/dashboard-files/2026/04/R2604\$(printf '%04d' \$i)_resume_individu.pdf\"
|
||||
done
|
||||
"
|
||||
|
||||
# Upload live script
|
||||
scp scripts/demo_live.sh one@devcpone.aplikasi.web.id:/home/one/demo_live.sh
|
||||
ssh one@devcpone.aplikasi.web.id "chmod +x /home/one/demo_live.sh"
|
||||
|
||||
# Jalankan simulasi live saat demo
|
||||
ssh one@devcpone.aplikasi.web.id "/home/one/demo_live.sh 5"
|
||||
```
|
||||
|
||||
Detail lengkap lihat `scripts/README.md`.
|
||||
47
cpone-dashboard/Makefile
Normal file
47
cpone-dashboard/Makefile
Normal file
@@ -0,0 +1,47 @@
|
||||
BINARY=cpone-dashboard
|
||||
SERVER=one@devcpone.aplikasi.web.id
|
||||
DEPLOY_DIR=/home/one/project/cpone-dashboard
|
||||
GO ?= go
|
||||
|
||||
.PHONY: run build deploy local stop start restart
|
||||
|
||||
run:
|
||||
$(GO) run .
|
||||
|
||||
local:
|
||||
GOCACHE=/tmp/cpone-gocache $(GO) run .
|
||||
|
||||
start:
|
||||
@if lsof -nP -iTCP:3307 -sTCP:LISTEN >/dev/null 2>&1; then \
|
||||
echo "tunnel 3307 already running"; \
|
||||
else \
|
||||
echo "starting tunnel 3307 -> devcpone"; \
|
||||
ssh -f -N -L 3307:127.0.0.1:3306 one@devcpone.aplikasi.web.id; \
|
||||
fi
|
||||
@echo "starting dashboard on http://localhost:8080/dashboard"
|
||||
GOCACHE=/tmp/cpone-gocache $(GO) run .
|
||||
|
||||
stop:
|
||||
@pids="$$(lsof -t -iTCP:8080 -sTCP:LISTEN 2>/dev/null) $$(lsof -t -iTCP:3307 -sTCP:LISTEN 2>/dev/null)"; \
|
||||
if [ -z "$$pids" ]; then \
|
||||
echo "nothing to stop"; \
|
||||
else \
|
||||
echo "$$pids" | tr ' ' '\n' | sort -u | xargs kill; \
|
||||
echo "stopped local dashboard and tunnel"; \
|
||||
fi
|
||||
|
||||
restart: stop start
|
||||
|
||||
build:
|
||||
GOOS=linux GOARCH=amd64 $(GO) build -o $(BINARY)-linux .
|
||||
|
||||
deploy: build
|
||||
scp $(BINARY)-linux $(SERVER):$(DEPLOY_DIR)/$(BINARY)
|
||||
ssh $(SERVER) "chmod +x $(DEPLOY_DIR)/$(BINARY) && systemctl --user restart cpone-dashboard"
|
||||
@echo "deployed → https://devcpone.aplikasi.web.id/cpone-dashboard"
|
||||
|
||||
logs:
|
||||
ssh $(SERVER) "journalctl --user -u cpone-dashboard -f --no-pager"
|
||||
|
||||
status:
|
||||
ssh $(SERVER) "systemctl --user status cpone-dashboard --no-pager"
|
||||
244
cpone-dashboard/README.md
Normal file
244
cpone-dashboard/README.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# CPONE Dashboard
|
||||
|
||||
Web dashboard berbasis Go untuk monitoring CPONE.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.22+
|
||||
- Akses SSH ke `devcpone.aplikasi.web.id` (untuk koneksi ke database)
|
||||
|
||||
## Cara Menjalankan
|
||||
|
||||
### 1. Setup `.env`
|
||||
|
||||
Salin `.env.example` menjadi `.env`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Isi `.env` dengan credential yang sesuai:
|
||||
|
||||
```
|
||||
APP_PORT=8080
|
||||
DB_DSN=user:password@tcp(127.0.0.1:3307)/cpone_dashboard?parseTime=true&loc=Local
|
||||
```
|
||||
|
||||
> Port `3307` digunakan karena database diakses melalui SSH tunnel (bukan langsung port 3306).
|
||||
|
||||
### 2. Buka SSH Tunnel ke Database
|
||||
|
||||
Jalankan perintah ini di terminal **terpisah** (atau di background):
|
||||
|
||||
```bash
|
||||
ssh -f -N -L 3307:127.0.0.1:3306 one@devcpone.aplikasi.web.id
|
||||
```
|
||||
|
||||
Kalau muncul error `Address already in use`, berarti tunnel sudah aktif — lanjut ke langkah berikutnya.
|
||||
|
||||
### 3. Jalankan App
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
App berjalan di: [http://localhost:8080/dashboard](http://localhost:8080/dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Cara Menghentikan
|
||||
|
||||
Hentikan tunnel dan app sekaligus:
|
||||
|
||||
```bash
|
||||
make stop
|
||||
```
|
||||
|
||||
Atau manual — cari dan kill prosesnya:
|
||||
|
||||
```bash
|
||||
# Hentikan app (port 8080)
|
||||
lsof -ti:8080 | xargs kill
|
||||
|
||||
# Hentikan SSH tunnel (port 3307)
|
||||
lsof -ti:3307 | xargs kill
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kustomisasi Tampilan
|
||||
|
||||
### Ganti Logo
|
||||
|
||||
Timpa file berikut dengan logo baru (format PNG, background transparan atau putih):
|
||||
|
||||
```
|
||||
static/img/logo.png
|
||||
```
|
||||
|
||||
Tidak perlu ubah kode — logo langsung berubah di seluruh halaman.
|
||||
|
||||
### Ganti Warna Utama
|
||||
|
||||
Semua warna primary dikendalikan dari satu tempat di `templates/layout/base.html`:
|
||||
|
||||
```js
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef0fb',
|
||||
100: '#dde2f7',
|
||||
...
|
||||
500: '#3b50a0', // <-- warna utama (header, badge, dll)
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ubah nilai hex di blok `brand`, semua elemen yang pakai `brand-*` ikut berubah.
|
||||
|
||||
---
|
||||
|
||||
## Autentikasi & Manajemen User
|
||||
|
||||
### Alur Login
|
||||
|
||||
```
|
||||
Browser Go (auth middleware) DB (dashboard_user)
|
||||
│ │ │
|
||||
│ GET /dashboard │ │
|
||||
│ ────────────────────────► │ cek cookie session │
|
||||
│ │ (tidak ada / invalid) │
|
||||
│ redirect /login ◄────── │ │
|
||||
│ │ │
|
||||
│ POST /login │ │
|
||||
│ username=budi ───► │ SELECT User_Password, │
|
||||
│ password=xxx │ User_Salt ──► │
|
||||
│ │ ◄──────── │ hash + salt
|
||||
│ │ SHA2(salt:password, 256) │
|
||||
│ │ cocokkan dengan hash │
|
||||
│ │ │
|
||||
│ set cookie + redirect ◄─ │ ✓ cocok → set session cookie│
|
||||
│ ke /dashboard │ │
|
||||
```
|
||||
|
||||
### Alur Password Hashing
|
||||
|
||||
Password tidak pernah disimpan dalam bentuk plain text. Setiap user punya **salt unik** yang di-generate otomatis saat insert/update.
|
||||
|
||||
```
|
||||
sp_upsert_dashboard_user('budi', 'password123', 'Budi Santoso')
|
||||
│
|
||||
├─ v_salt = UUID() → contoh: "a91f-yyyy-..."
|
||||
├─ v_hash = SHA2("a91f-yyyy-...:password123", 256)
|
||||
│
|
||||
└─ simpan ke DB:
|
||||
User_Password = "f3c2..." ← hash
|
||||
User_Salt = "a91f-..." ← salt
|
||||
```
|
||||
|
||||
Efeknya: dua user dengan password yang sama tetap menghasilkan hash yang berbeda di DB, karena salt-nya berbeda.
|
||||
|
||||
### Menambah User Baru
|
||||
|
||||
```sql
|
||||
-- Error jika username sudah terdaftar
|
||||
CALL sp_insert_dashboard_user('budi', 'password123', 'Budi Santoso');
|
||||
```
|
||||
|
||||
### Reset Password
|
||||
|
||||
```sql
|
||||
-- Error jika username tidak ditemukan
|
||||
CALL sp_reset_dashboard_user_password('budi', 'passwordbaru');
|
||||
```
|
||||
|
||||
### Nonaktifkan User
|
||||
|
||||
```sql
|
||||
UPDATE dashboard_user SET User_IsActive = 'N' WHERE User_Username = 'budi';
|
||||
```
|
||||
|
||||
### Struktur Tabel `dashboard_user`
|
||||
|
||||
| Kolom | Tipe | Keterangan |
|
||||
|---|---|---|
|
||||
| `User_ID` | INT | Primary key, auto increment |
|
||||
| `User_Username` | VARCHAR(50) | Login username, unique |
|
||||
| `User_Password` | VARCHAR(64) | SHA2-256 hash |
|
||||
| `User_Salt` | VARCHAR(36) | UUID salt, unik per user/reset |
|
||||
| `User_DisplayName` | VARCHAR(100) | Nama yang ditampilkan di header |
|
||||
| `User_IsActive` | CHAR(1) | `Y` = aktif, `N` = nonaktif |
|
||||
|
||||
---
|
||||
|
||||
## Mapping User ke MCU Project
|
||||
|
||||
Satu user bisa diassign ke lebih dari satu `mcu_project`. Relasi disimpan di tabel `dashboard_user_project`.
|
||||
|
||||
### Relasi
|
||||
|
||||
```
|
||||
dashboard_user dashboard_user_project mcu_project
|
||||
────────────── ────────────────────── ───────────
|
||||
User_ID ◄──── UserProj_UserID Mcu_ProjectMcuID
|
||||
UserProj_McuID ────►
|
||||
UserProj_IsActive
|
||||
```
|
||||
|
||||
### Assign / Cabut Akses Project
|
||||
|
||||
```sql
|
||||
-- Assign user ke satu project
|
||||
CALL sp_assign_user_project('budi', 101);
|
||||
|
||||
-- Assign ke project lain (bisa lebih dari satu)
|
||||
CALL sp_assign_user_project('budi', 205);
|
||||
|
||||
-- Cabut akses dari satu project (soft delete, data tetap ada)
|
||||
CALL sp_remove_user_project('budi', 101);
|
||||
```
|
||||
|
||||
### Lihat Project yang Bisa Diakses User
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
u.User_Username,
|
||||
u.User_DisplayName,
|
||||
p.Mcu_ProjectMcuID,
|
||||
p.Mcu_ProjectLabel,
|
||||
p.Mcu_ProjectCorporateName,
|
||||
up.UserProj_IsActive
|
||||
FROM dashboard_user_project up
|
||||
JOIN dashboard_user u ON u.User_ID = up.UserProj_UserID
|
||||
JOIN mcu_project p ON p.Mcu_ProjectMcuID = up.UserProj_McuID
|
||||
WHERE u.User_Username = 'budi'
|
||||
AND up.UserProj_IsActive = 'Y';
|
||||
```
|
||||
|
||||
### Struktur Tabel `dashboard_user_project`
|
||||
|
||||
| Kolom | Tipe | Keterangan |
|
||||
|---|---|---|
|
||||
| `UserProj_ID` | INT | Primary key, auto increment |
|
||||
| `UserProj_UserID` | INT | Referensi ke `dashboard_user.User_ID` |
|
||||
| `UserProj_McuID` | INT | Referensi ke `mcu_project.Mcu_ProjectMcuID` |
|
||||
| `UserProj_IsActive` | CHAR(1) | `Y` = aktif, `N` = dicabut |
|
||||
|
||||
---
|
||||
|
||||
## Shortcut via Makefile
|
||||
|
||||
| Perintah | Keterangan |
|
||||
|---|---|
|
||||
| `make start` | Buka SSH tunnel + jalankan app |
|
||||
| `make stop` | Hentikan app dan SSH tunnel |
|
||||
| `make restart` | Stop lalu start ulang |
|
||||
| `make run` | Jalankan app saja (tanpa buka tunnel) |
|
||||
| `make build` | Build binary untuk Linux |
|
||||
| `make deploy` | Build + deploy ke server |
|
||||
BIN
cpone-dashboard/assets/logo.png
Normal file
BIN
cpone-dashboard/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
37
cpone-dashboard/config/config.go
Normal file
37
cpone-dashboard/config/config.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppPort string
|
||||
DBDSN string
|
||||
AuthSecret string
|
||||
PDFBaseURL string
|
||||
BasePath string // e.g. "/cpone-dashboard" or "" for root
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("no .env file, reading from environment")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
AppPort: getEnv("APP_PORT", "8080"),
|
||||
DBDSN: getEnv("DB_DSN", ""),
|
||||
AuthSecret: getEnv("AUTH_SECRET", "cpone-change-this-secret"),
|
||||
PDFBaseURL: getEnv("PDF_BASE_URL", ""),
|
||||
BasePath: getEnv("BASE_PATH", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
33
cpone-dashboard/db/db.go
Normal file
33
cpone-dashboard/db/db.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
func Connect(dsn string) error {
|
||||
if dsn == "" {
|
||||
return fmt.Errorf("DB_DSN is not set")
|
||||
}
|
||||
|
||||
conn, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.Ping(); err != nil {
|
||||
return fmt.Errorf("ping db: %w", err)
|
||||
}
|
||||
|
||||
conn.SetMaxOpenConns(25)
|
||||
conn.SetMaxIdleConns(10)
|
||||
|
||||
DB = conn
|
||||
log.Println("database connected: cpone_dashboard")
|
||||
return nil
|
||||
}
|
||||
123
cpone-dashboard/db/migrations/001_init_schema.sql
Normal file
123
cpone-dashboard/db/migrations/001_init_schema.sql
Normal file
@@ -0,0 +1,123 @@
|
||||
-- Migration 001: Initial schema for cpone_dashboard
|
||||
-- Naming convention: table=snake_case, column=Prefix_PascalCase (mengikuti cpone)
|
||||
-- Tidak menggunakan foreign key constraint
|
||||
|
||||
-- ============================================================
|
||||
-- mcu_project
|
||||
-- Source: cpone.mgm_mcu JOIN cpone.corporate
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS mcu_project (
|
||||
Mcu_ProjectID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Mcu_ProjectMcuID INT NOT NULL, -- Mgm_McuID
|
||||
Mcu_ProjectCorporateID INT NOT NULL, -- Mgm_McuCorporateID
|
||||
Mcu_ProjectCorporateName VARCHAR(255), -- corporate.CorporateName
|
||||
Mcu_ProjectNumber VARCHAR(50), -- Mgm_McuNumber
|
||||
Mcu_ProjectLabel VARCHAR(255), -- Mgm_McuLabel
|
||||
Mcu_ProjectBranchID INT DEFAULT 0, -- Mgm_McuM_BranchID
|
||||
Mcu_ProjectStartDate DATE, -- Mgm_McuStartDate
|
||||
Mcu_ProjectEndDate DATE, -- Mgm_McuEndDate
|
||||
Mcu_ProjectIsActive CHAR(1) DEFAULT 'Y', -- Mgm_McuIsActive
|
||||
Mcu_ProjectTotalParticipant INT DEFAULT 0, -- Mgm_McuTotalParticipant
|
||||
Mcu_ProjectSyncedAt DATETIME,
|
||||
UNIQUE KEY uq_mcu_id (Mcu_ProjectMcuID),
|
||||
INDEX idx_is_active (Mcu_ProjectIsActive),
|
||||
INDEX idx_dates (Mcu_ProjectStartDate, Mcu_ProjectEndDate)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- mcu_patient
|
||||
-- Source: cpone.mcu_preregister_patients
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS mcu_patient (
|
||||
Mcu_PatientID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Mcu_PatientPreregisterID INT NOT NULL, -- Mcu_PreregisterPatientsID
|
||||
Mcu_PatientMcuID INT NOT NULL, -- Mcu_PreregisterPatientsMgm_McuID
|
||||
Mcu_PatientName VARCHAR(150), -- Mcu_PreregisterPatientsPatientName
|
||||
Mcu_PatientNIP VARCHAR(50), -- Mcu_PreregisterPatientsNIP
|
||||
Mcu_PatientGender VARCHAR(10), -- Mcu_PreregisterPatientsGender
|
||||
Mcu_PatientDOB DATE, -- Mcu_PreregisterPatientsDOB
|
||||
Mcu_PatientDepartment VARCHAR(500), -- Mcu_PreregisterPatientsDepartment
|
||||
Mcu_PatientDivision VARCHAR(500), -- Mcu_PreregisterPatientsDivisi
|
||||
Mcu_PatientPosisi VARCHAR(500), -- Mcu_PreregisterPatientsPosisi
|
||||
Mcu_PatientIsRegistered CHAR(1) DEFAULT 'N', -- Mcu_PreregisterPatientsIsRegistered
|
||||
Mcu_PatientOrderID INT DEFAULT 0, -- Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
Mcu_PatientIsActive CHAR(1) DEFAULT 'Y', -- Mcu_PreregisterPatientsIsActive
|
||||
Mcu_PatientSyncedAt DATETIME,
|
||||
UNIQUE KEY uq_preregister_id (Mcu_PatientPreregisterID),
|
||||
INDEX idx_mcu_id (Mcu_PatientMcuID),
|
||||
INDEX idx_order_id (Mcu_PatientOrderID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- mcu_patient_schedule
|
||||
-- Source: cpone.mcu_preregister_date
|
||||
-- Catatan: tidak semua pasien punya jadwal (opsional)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS mcu_patient_schedule (
|
||||
Mcu_PatientScheduleID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Mcu_PatientSchedulePreregisterID INT NOT NULL, -- Mcu_PreregisterDateMcu_PreregisterPatientsID
|
||||
Mcu_PatientScheduleDate DATE NOT NULL, -- Mcu_PreregisterDateCheckinSchedule
|
||||
Mcu_PatientScheduleIsActive CHAR(1) DEFAULT 'Y', -- Mcu_PreregisterDateIsActive
|
||||
Mcu_PatientScheduleSyncedAt DATETIME,
|
||||
INDEX idx_preregister_id (Mcu_PatientSchedulePreregisterID),
|
||||
INDEX idx_schedule_date (Mcu_PatientScheduleDate)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- mcu_checkinout
|
||||
-- Source: cpone.preregister_checkin_checkout
|
||||
-- Catatan: 1 pasien bisa punya > 1 baris (multi-hari)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS mcu_checkinout (
|
||||
Mcu_CheckinoutID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Mcu_CheckinoutCheckinoutID INT NOT NULL, -- PreregisterCheckInCheckOutID
|
||||
Mcu_CheckinoutPreregisterID INT NOT NULL, -- PreregisterCheckInCheckOutPreregisterID
|
||||
Mcu_CheckinoutOrderID INT DEFAULT 0, -- PreregisterCheckInCheckOutT_OrderHeaderID
|
||||
Mcu_CheckinoutDate DATE NOT NULL, -- PreregisterCheckInCheckOutDate
|
||||
Mcu_CheckinoutInTime TIME, -- PreregisterCheckInCheckOutInTime
|
||||
Mcu_CheckinoutOutTime TIME, -- PreregisterCheckInCheckOutOutTime
|
||||
Mcu_CheckinoutOutUserID INT DEFAULT 0, -- PreregisterCheckInCheckOutOutUserID
|
||||
Mcu_CheckinoutNextDate DATE, -- PreregisterCheckInCheckOutNextPreregisterDate
|
||||
Mcu_CheckinoutNote TEXT, -- PreregisterCheckInCheckOutNote
|
||||
Mcu_CheckinoutIsActive CHAR(1) DEFAULT 'Y', -- PreregisterCheckInCheckOutIsActive
|
||||
Mcu_CheckinoutSyncedAt DATETIME,
|
||||
UNIQUE KEY uq_checkinout_segment (
|
||||
Mcu_CheckinoutPreregisterID,
|
||||
Mcu_CheckinoutDate,
|
||||
Mcu_CheckinoutInTime
|
||||
),
|
||||
INDEX idx_preregister_id (Mcu_CheckinoutPreregisterID),
|
||||
INDEX idx_date (Mcu_CheckinoutDate),
|
||||
INDEX idx_order_id (Mcu_CheckinoutOrderID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- mcu_station_progress (Phase 2)
|
||||
-- Source: cpone.t_ordersample (lab)
|
||||
-- + cpone.t_orderdetail->t_test->group_resultdetail->group_result (nonlab)
|
||||
-- ETL flatten kedua sumber menjadi satu tabel
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS mcu_station_progress (
|
||||
Mcu_StationProgressID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Mcu_StationProgressOrderID INT NOT NULL, -- T_OrderHeaderID
|
||||
Mcu_StationProgressPreregisterID INT NOT NULL,
|
||||
Mcu_StationProgressMcuID INT NOT NULL,
|
||||
Mcu_StationProgressStationID INT NOT NULL, -- T_SampleStationID
|
||||
Mcu_StationProgressStationName VARCHAR(100), -- T_SampleStationName
|
||||
Mcu_StationProgressSource VARCHAR(10), -- 'lab' | 'nonlab'
|
||||
Mcu_StationProgressCheckinDate DATE, -- tanggal checkin pasien
|
||||
Mcu_StationProgressSamplingAt DATETIME, -- lab: SamplingDate+Time
|
||||
Mcu_StationProgressReceiveAt DATETIME, -- lab: ReceiveDate+Time
|
||||
Mcu_StationProgressProcessAt DATETIME, -- lab: ProcessingDate+Time
|
||||
Mcu_StationProgressDoneAt DATETIME, -- lab: DoneDate+Time | nonlab: result entry time
|
||||
Mcu_StationProgressSyncedAt DATETIME,
|
||||
INDEX idx_order_id (Mcu_StationProgressOrderID),
|
||||
INDEX idx_preregister_id (Mcu_StationProgressPreregisterID),
|
||||
INDEX idx_mcu_id (Mcu_StationProgressMcuID),
|
||||
INDEX idx_station_id (Mcu_StationProgressStationID),
|
||||
INDEX idx_checkin_date (Mcu_StationProgressCheckinDate)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
250
cpone-dashboard/db/migrations/002_sp_generate_data.sql
Normal file
250
cpone-dashboard/db/migrations/002_sp_generate_data.sql
Normal file
@@ -0,0 +1,250 @@
|
||||
-- Migration 002: stored procedure untuk generate dashboard data dari project cpone yang sudah ada
|
||||
-- Digunakan untuk testing dengan data historis (contoh: Mgm_McuID = 99)
|
||||
|
||||
-- Tambah unique key di mcu_patient_schedule agar ON DUPLICATE KEY UPDATE bisa berjalan
|
||||
ALTER TABLE mcu_patient_schedule
|
||||
ADD UNIQUE KEY uq_preregister_schedule (Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate);
|
||||
|
||||
ALTER TABLE mcu_checkinout
|
||||
DROP INDEX uq_checkinout_id,
|
||||
ADD UNIQUE KEY uq_checkinout_segment (
|
||||
Mcu_CheckinoutPreregisterID,
|
||||
Mcu_CheckinoutDate,
|
||||
Mcu_CheckinoutInTime
|
||||
);
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- sp_generate_dashboard_data
|
||||
-- Parameter: p_mcu_id INT (Mgm_McuID dari cpone)
|
||||
-- Mengisi: mcu_project, mcu_patient, mcu_patient_schedule, mcu_checkinout
|
||||
-- ============================================================
|
||||
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
|
||||
BEGIN
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 1. mcu_project
|
||||
-- Source: cpone.mgm_mcu JOIN cpone.corporate
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_project (
|
||||
Mcu_ProjectMcuID,
|
||||
Mcu_ProjectCorporateID,
|
||||
Mcu_ProjectCorporateName,
|
||||
Mcu_ProjectNumber,
|
||||
Mcu_ProjectLabel,
|
||||
Mcu_ProjectBranchID,
|
||||
Mcu_ProjectStartDate,
|
||||
Mcu_ProjectEndDate,
|
||||
Mcu_ProjectIsActive,
|
||||
Mcu_ProjectTotalParticipant,
|
||||
Mcu_ProjectSyncedAt
|
||||
)
|
||||
SELECT
|
||||
m.Mgm_McuID,
|
||||
m.Mgm_McuCorporateID,
|
||||
c.CorporateName,
|
||||
m.Mgm_McuNumber,
|
||||
m.Mgm_McuLabel,
|
||||
m.Mgm_McuM_BranchID,
|
||||
m.Mgm_McuStartDate,
|
||||
m.Mgm_McuEndDate,
|
||||
m.Mgm_McuIsActive,
|
||||
m.Mgm_McuTotalParticipant,
|
||||
NOW()
|
||||
FROM cpone.mgm_mcu m
|
||||
LEFT JOIN cpone.corporate c ON c.CorporateID = m.Mgm_McuCorporateID
|
||||
WHERE m.Mgm_McuID = p_mcu_id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_ProjectCorporateName = VALUES(Mcu_ProjectCorporateName),
|
||||
Mcu_ProjectLabel = VALUES(Mcu_ProjectLabel),
|
||||
Mcu_ProjectIsActive = VALUES(Mcu_ProjectIsActive),
|
||||
Mcu_ProjectTotalParticipant = VALUES(Mcu_ProjectTotalParticipant),
|
||||
Mcu_ProjectSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 2. mcu_patient
|
||||
-- Source: cpone.mcu_preregister_patients
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_patient (
|
||||
Mcu_PatientPreregisterID,
|
||||
Mcu_PatientMcuID,
|
||||
Mcu_PatientName,
|
||||
Mcu_PatientNIP,
|
||||
Mcu_PatientGender,
|
||||
Mcu_PatientDOB,
|
||||
Mcu_PatientDepartment,
|
||||
Mcu_PatientDivision,
|
||||
Mcu_PatientPosisi,
|
||||
Mcu_PatientIsRegistered,
|
||||
Mcu_PatientOrderID,
|
||||
Mcu_PatientIsActive,
|
||||
Mcu_PatientSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
p.Mcu_PreregisterPatientsMgm_McuID,
|
||||
p.Mcu_PreregisterPatientsPatientName,
|
||||
p.Mcu_PreregisterPatientsNIP,
|
||||
p.Mcu_PreregisterPatientsGender,
|
||||
p.Mcu_PreregisterPatientsDOB,
|
||||
p.Mcu_PreregisterPatientsDepartment,
|
||||
p.Mcu_PreregisterPatientsDivisi,
|
||||
p.Mcu_PreregisterPatientsPosisi,
|
||||
p.Mcu_PreregisterPatientsIsRegistered,
|
||||
p.Mcu_PreregisterPatientsT_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsIsActive,
|
||||
NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_PatientName = VALUES(Mcu_PatientName),
|
||||
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
|
||||
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
|
||||
Mcu_PatientSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 3. mcu_patient_schedule
|
||||
-- Source: T_OrderHeaderDate sebagai jadwal checkin
|
||||
-- Hanya untuk pasien yang sudah punya order
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_patient_schedule (
|
||||
Mcu_PatientSchedulePreregisterID,
|
||||
Mcu_PatientScheduleDate,
|
||||
Mcu_PatientScheduleIsActive,
|
||||
Mcu_PatientScheduleSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
DATE(o.T_OrderHeaderDate),
|
||||
'Y',
|
||||
NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o
|
||||
ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_PatientScheduleSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 4. mcu_checkinout
|
||||
-- Satu baris per tanggal proses.
|
||||
-- Check-in : tanggal header untuk hari pertama, atau event paling awal untuk hari lanjutan.
|
||||
-- Check-out : event paling akhir pada tanggal itu.
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_checkinout (
|
||||
Mcu_CheckinoutCheckinoutID,
|
||||
Mcu_CheckinoutPreregisterID,
|
||||
Mcu_CheckinoutOrderID,
|
||||
Mcu_CheckinoutDate,
|
||||
Mcu_CheckinoutInTime,
|
||||
Mcu_CheckinoutOutTime,
|
||||
Mcu_CheckinoutNextDate,
|
||||
Mcu_CheckinoutIsActive,
|
||||
Mcu_CheckinoutSyncedAt
|
||||
)
|
||||
SELECT
|
||||
src.order_id,
|
||||
src.preregister_id,
|
||||
src.order_id,
|
||||
src.segment_date,
|
||||
TIME(src.checkin_dt),
|
||||
TIME(src.checkout_dt),
|
||||
CASE
|
||||
WHEN DATE(src.checkout_dt) = src.segment_date THEN NULL
|
||||
ELSE DATE(src.checkout_dt)
|
||||
END,
|
||||
'Y',
|
||||
NOW()
|
||||
FROM (
|
||||
SELECT
|
||||
o.T_OrderHeaderID AS order_id,
|
||||
p.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o.T_OrderHeaderDate AS header_dt,
|
||||
ev.segment_date,
|
||||
CASE
|
||||
WHEN ev.segment_date = DATE(o.T_OrderHeaderDate) THEN o.T_OrderHeaderDate
|
||||
ELSE MIN(ev.event_dt)
|
||||
END AS checkin_dt,
|
||||
MAX(ev.event_dt) AS checkout_dt
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o
|
||||
ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN (
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(o2.T_OrderHeaderDate) AS segment_date,
|
||||
o2.T_OrderHeaderDate AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2
|
||||
ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
UNION ALL
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)) AS segment_date,
|
||||
TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime) AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2
|
||||
ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_ordersample s2
|
||||
ON s2.T_OrderSampleT_OrderHeaderID = o2.T_OrderHeaderID
|
||||
AND s2.T_OrderSampleIsActive = 'Y'
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
AND s2.T_OrderSampleReceiveDate IS NOT NULL
|
||||
AND s2.T_OrderSampleReceiveTime IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)) AS segment_date,
|
||||
TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime) AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2
|
||||
ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_samplingso ts2
|
||||
ON ts2.T_SamplingSoT_OrderHeaderID = o2.T_OrderHeaderID
|
||||
AND ts2.T_SamplingSoIsActive = 'Y'
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
AND ts2.T_SamplingSoDoneDate IS NOT NULL
|
||||
AND ts2.T_SamplingSoDoneTime IS NOT NULL
|
||||
) ev
|
||||
ON ev.preregister_id = p.Mcu_PreregisterPatientsID
|
||||
AND ev.order_id = o.T_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY
|
||||
o.T_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
o.T_OrderHeaderDate,
|
||||
ev.segment_date
|
||||
) AS src
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
|
||||
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
|
||||
Mcu_CheckinoutSyncedAt = NOW();
|
||||
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
269
cpone-dashboard/db/migrations/003_sp_add_station_progress.sql
Normal file
269
cpone-dashboard/db/migrations/003_sp_add_station_progress.sql
Normal file
@@ -0,0 +1,269 @@
|
||||
-- Migration 003: tambah unique key mcu_station_progress + update SP dengan station progress
|
||||
|
||||
ALTER TABLE mcu_station_progress
|
||||
ADD UNIQUE KEY uq_order_station_source (
|
||||
Mcu_StationProgressOrderID,
|
||||
Mcu_StationProgressStationID,
|
||||
Mcu_StationProgressSource
|
||||
);
|
||||
|
||||
ALTER TABLE mcu_checkinout
|
||||
DROP INDEX uq_checkinout_id,
|
||||
ADD UNIQUE KEY uq_checkinout_segment (
|
||||
Mcu_CheckinoutPreregisterID,
|
||||
Mcu_CheckinoutDate,
|
||||
Mcu_CheckinoutInTime
|
||||
);
|
||||
|
||||
|
||||
-- Update SP: tambah step 5 untuk mcu_station_progress
|
||||
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
|
||||
BEGIN
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 1. mcu_project
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_project (
|
||||
Mcu_ProjectMcuID, Mcu_ProjectCorporateID, Mcu_ProjectCorporateName,
|
||||
Mcu_ProjectNumber, Mcu_ProjectLabel, Mcu_ProjectBranchID,
|
||||
Mcu_ProjectStartDate, Mcu_ProjectEndDate, Mcu_ProjectIsActive,
|
||||
Mcu_ProjectTotalParticipant, Mcu_ProjectSyncedAt
|
||||
)
|
||||
SELECT
|
||||
m.Mgm_McuID, m.Mgm_McuCorporateID, c.CorporateName,
|
||||
m.Mgm_McuNumber, m.Mgm_McuLabel, m.Mgm_McuM_BranchID,
|
||||
m.Mgm_McuStartDate, m.Mgm_McuEndDate, m.Mgm_McuIsActive,
|
||||
m.Mgm_McuTotalParticipant, NOW()
|
||||
FROM cpone.mgm_mcu m
|
||||
LEFT JOIN cpone.corporate c ON c.CorporateID = m.Mgm_McuCorporateID
|
||||
WHERE m.Mgm_McuID = p_mcu_id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_ProjectCorporateName = VALUES(Mcu_ProjectCorporateName),
|
||||
Mcu_ProjectLabel = VALUES(Mcu_ProjectLabel),
|
||||
Mcu_ProjectIsActive = VALUES(Mcu_ProjectIsActive),
|
||||
Mcu_ProjectTotalParticipant = VALUES(Mcu_ProjectTotalParticipant),
|
||||
Mcu_ProjectSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 2. mcu_patient
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_patient (
|
||||
Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName,
|
||||
Mcu_PatientNIP, Mcu_PatientGender, Mcu_PatientDOB,
|
||||
Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi,
|
||||
Mcu_PatientIsRegistered, Mcu_PatientOrderID, Mcu_PatientIsActive,
|
||||
Mcu_PatientSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p.Mcu_PreregisterPatientsID, p.Mcu_PreregisterPatientsMgm_McuID,
|
||||
p.Mcu_PreregisterPatientsPatientName, p.Mcu_PreregisterPatientsNIP,
|
||||
p.Mcu_PreregisterPatientsGender, p.Mcu_PreregisterPatientsDOB,
|
||||
p.Mcu_PreregisterPatientsDepartment, p.Mcu_PreregisterPatientsDivisi,
|
||||
p.Mcu_PreregisterPatientsPosisi,
|
||||
p.Mcu_PreregisterPatientsIsRegistered, p.Mcu_PreregisterPatientsT_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsIsActive, NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_PatientName = VALUES(Mcu_PatientName),
|
||||
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
|
||||
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
|
||||
Mcu_PatientSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 3. mcu_patient_schedule
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_patient_schedule (
|
||||
Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate,
|
||||
Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p.Mcu_PreregisterPatientsID, DATE(o.T_OrderHeaderDate), 'Y', NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_PatientScheduleSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 4. mcu_checkinout
|
||||
-- Satu baris per tanggal proses.
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_checkinout (
|
||||
Mcu_CheckinoutCheckinoutID, Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
|
||||
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
|
||||
Mcu_CheckinoutNextDate, Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt
|
||||
)
|
||||
SELECT
|
||||
src.order_id,
|
||||
src.preregister_id,
|
||||
src.order_id,
|
||||
src.segment_date,
|
||||
TIME(src.checkin_dt),
|
||||
TIME(src.checkout_dt),
|
||||
CASE
|
||||
WHEN DATE(src.checkout_dt) = src.segment_date THEN NULL
|
||||
ELSE DATE(src.checkout_dt)
|
||||
END,
|
||||
'Y', NOW()
|
||||
FROM (
|
||||
SELECT
|
||||
o.T_OrderHeaderID AS order_id,
|
||||
p.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o.T_OrderHeaderDate AS header_dt,
|
||||
ev.segment_date,
|
||||
CASE
|
||||
WHEN ev.segment_date = DATE(o.T_OrderHeaderDate) THEN o.T_OrderHeaderDate
|
||||
ELSE MIN(ev.event_dt)
|
||||
END AS checkin_dt,
|
||||
MAX(ev.event_dt) AS checkout_dt
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN (
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(o2.T_OrderHeaderDate) AS segment_date,
|
||||
o2.T_OrderHeaderDate AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
UNION ALL
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)) AS segment_date,
|
||||
TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime) AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_ordersample s2
|
||||
ON s2.T_OrderSampleT_OrderHeaderID = o2.T_OrderHeaderID
|
||||
AND s2.T_OrderSampleIsActive = 'Y'
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
AND s2.T_OrderSampleReceiveDate IS NOT NULL
|
||||
AND s2.T_OrderSampleReceiveTime IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)) AS segment_date,
|
||||
TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime) AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_samplingso ts2
|
||||
ON ts2.T_SamplingSoT_OrderHeaderID = o2.T_OrderHeaderID
|
||||
AND ts2.T_SamplingSoIsActive = 'Y'
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
AND ts2.T_SamplingSoDoneDate IS NOT NULL
|
||||
AND ts2.T_SamplingSoDoneTime IS NOT NULL
|
||||
) ev
|
||||
ON ev.preregister_id = p.Mcu_PreregisterPatientsID
|
||||
AND ev.order_id = o.T_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, o.T_OrderHeaderDate, ev.segment_date
|
||||
) AS src
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
|
||||
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
|
||||
Mcu_CheckinoutSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 5. mcu_station_progress — lab (dari t_ordersample)
|
||||
-- GROUP BY order+station supaya satu baris per pasien per station
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_station_progress (
|
||||
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
|
||||
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
|
||||
Mcu_StationProgressCheckinDate,
|
||||
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
|
||||
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
|
||||
)
|
||||
SELECT
|
||||
o.T_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
p_mcu_id,
|
||||
s.T_OrderSampleT_SampleStationID,
|
||||
ss.T_SampleStationName,
|
||||
'lab',
|
||||
DATE(o.T_OrderHeaderDate),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleReceiveDate, s.T_OrderSampleReceiveTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleProcessingDate, s.T_OrderSampleProcessingTime)), '0000-00-00 00:00:00'),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_ordersample s ON s.T_OrderSampleT_OrderHeaderID = o.T_OrderHeaderID AND s.T_OrderSampleIsActive = 'Y'
|
||||
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = s.T_OrderSampleT_SampleStationID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_StationProgressSamplingAt = VALUES(Mcu_StationProgressSamplingAt),
|
||||
Mcu_StationProgressReceiveAt = VALUES(Mcu_StationProgressReceiveAt),
|
||||
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
|
||||
Mcu_StationProgressSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 6. mcu_station_progress — nonlab (dari t_samplingso)
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_station_progress (
|
||||
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
|
||||
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
|
||||
Mcu_StationProgressCheckinDate,
|
||||
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
|
||||
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
|
||||
)
|
||||
SELECT
|
||||
o.T_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
p_mcu_id,
|
||||
ts.T_SamplingSoT_SampleStationID,
|
||||
ss.T_SampleStationName,
|
||||
'nonlab',
|
||||
DATE(o.T_OrderHeaderDate),
|
||||
NULL,
|
||||
NULL,
|
||||
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoDoneDate, ts.T_SamplingSoDoneTime)), '0000-00-00 00:00:00'),
|
||||
NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_samplingso ts ON ts.T_SamplingSoT_OrderHeaderID = o.T_OrderHeaderID AND ts.T_SamplingSoIsActive = 'Y'
|
||||
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = ts.T_SamplingSoT_SampleStationID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
|
||||
Mcu_StationProgressDoneAt = VALUES(Mcu_StationProgressDoneAt),
|
||||
Mcu_StationProgressSyncedAt = NOW();
|
||||
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
17
cpone-dashboard/db/migrations/004_dashboard_user.sql
Normal file
17
cpone-dashboard/db/migrations/004_dashboard_user.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Migration 004: tabel user untuk login dashboard
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dashboard_user (
|
||||
User_ID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
User_Username VARCHAR(50) NOT NULL,
|
||||
User_Password VARCHAR(255) NOT NULL, -- bcrypt hash
|
||||
User_DisplayName VARCHAR(100),
|
||||
User_IsActive CHAR(1) DEFAULT 'Y',
|
||||
User_CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
User_UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_username (User_Username),
|
||||
INDEX idx_is_active (User_IsActive)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Default user: admin / admin123 (ganti password setelah pertama login)
|
||||
INSERT IGNORE INTO dashboard_user (User_Username, User_Password, User_DisplayName)
|
||||
VALUES ('admin', '$2a$10$dArL7F97pp/JdtIYhY/xD.W5RqAgKiW/ZvJoY75b6oer7xrQx1UNC', 'Administrator');
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Migration 005: tambah kolom salt + stored procedure upsert dashboard user
|
||||
|
||||
ALTER TABLE dashboard_user
|
||||
ADD COLUMN User_Salt VARCHAR(36) DEFAULT NULL AFTER User_Password;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- sp_upsert_dashboard_user
|
||||
-- Parameter:
|
||||
-- p_username VARCHAR(50) -- required, unique key
|
||||
-- p_password VARCHAR(255) -- plain text, akan di-hash di dalam SP
|
||||
-- p_display_name VARCHAR(100) -- nama tampilan (boleh NULL)
|
||||
-- Behaviour:
|
||||
-- - INSERT jika username belum ada
|
||||
-- - UPDATE password + display_name jika sudah ada
|
||||
-- - Password di-hash: SHA2(UUID_salt + ':' + password, 256)
|
||||
-- ============================================================
|
||||
DROP PROCEDURE IF EXISTS sp_upsert_dashboard_user;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_upsert_dashboard_user(
|
||||
IN p_username VARCHAR(50),
|
||||
IN p_password VARCHAR(255),
|
||||
IN p_display_name VARCHAR(100)
|
||||
)
|
||||
BEGIN
|
||||
DECLARE v_salt VARCHAR(36);
|
||||
DECLARE v_hash VARCHAR(64);
|
||||
|
||||
-- Validasi input
|
||||
IF p_username IS NULL OR TRIM(p_username) = '' THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'Username tidak boleh kosong';
|
||||
END IF;
|
||||
|
||||
IF p_password IS NULL OR TRIM(p_password) = '' THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'Password tidak boleh kosong';
|
||||
END IF;
|
||||
|
||||
IF LENGTH(p_password) < 6 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'Password minimal 6 karakter';
|
||||
END IF;
|
||||
|
||||
-- Generate salt + hash
|
||||
SET v_salt = UUID();
|
||||
SET v_hash = SHA2(CONCAT(v_salt, ':', p_password), 256);
|
||||
|
||||
INSERT INTO dashboard_user
|
||||
(User_Username, User_Password, User_Salt, User_DisplayName, User_IsActive)
|
||||
VALUES
|
||||
(TRIM(p_username), v_hash, v_salt, p_display_name, 'Y')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
User_Password = v_hash,
|
||||
User_Salt = v_salt,
|
||||
User_DisplayName = COALESCE(p_display_name, User_DisplayName),
|
||||
User_IsActive = 'Y',
|
||||
User_UpdatedAt = NOW();
|
||||
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
|
||||
-- Re-seed admin menggunakan SP (mengganti hash bcrypt lama)
|
||||
CALL sp_upsert_dashboard_user('admin', 'admin123', 'Administrator');
|
||||
100
cpone-dashboard/db/migrations/006_dashboard_user_project.sql
Normal file
100
cpone-dashboard/db/migrations/006_dashboard_user_project.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
-- Migration 006: mapping user ke mcu_project (many-to-many)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dashboard_user_project (
|
||||
UserProj_ID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
UserProj_UserID INT NOT NULL, -- dashboard_user.User_ID
|
||||
UserProj_McuID INT NOT NULL, -- mcu_project.Mcu_ProjectMcuID
|
||||
UserProj_IsActive CHAR(1) DEFAULT 'Y',
|
||||
UserProj_CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UserProj_UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_user_mcu (UserProj_UserID, UserProj_McuID),
|
||||
INDEX idx_user_id (UserProj_UserID),
|
||||
INDEX idx_mcu_id (UserProj_McuID)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- sp_assign_user_project
|
||||
-- Assign satu mcu_project ke satu user (idempotent).
|
||||
-- Parameter:
|
||||
-- p_username VARCHAR(50) -- username di dashboard_user
|
||||
-- p_mcu_id INT -- Mcu_ProjectMcuID di mcu_project
|
||||
-- ============================================================
|
||||
DROP PROCEDURE IF EXISTS sp_assign_user_project;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_assign_user_project(
|
||||
IN p_username VARCHAR(50),
|
||||
IN p_mcu_id INT
|
||||
)
|
||||
BEGIN
|
||||
DECLARE v_user_id INT;
|
||||
|
||||
-- Cari user
|
||||
SELECT User_ID INTO v_user_id
|
||||
FROM dashboard_user
|
||||
WHERE User_Username = p_username AND User_IsActive = 'Y'
|
||||
LIMIT 1;
|
||||
|
||||
IF v_user_id IS NULL THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'User tidak ditemukan atau tidak aktif';
|
||||
END IF;
|
||||
|
||||
-- Validasi mcu_project ada
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM mcu_project WHERE Mcu_ProjectMcuID = p_mcu_id
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'mcu_id tidak ditemukan di mcu_project';
|
||||
END IF;
|
||||
|
||||
INSERT INTO dashboard_user_project (UserProj_UserID, UserProj_McuID, UserProj_IsActive)
|
||||
VALUES (v_user_id, p_mcu_id, 'Y')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
UserProj_IsActive = 'Y',
|
||||
UserProj_UpdatedAt = NOW();
|
||||
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- sp_remove_user_project
|
||||
-- Cabut akses satu mcu_project dari user (soft delete).
|
||||
-- Parameter:
|
||||
-- p_username VARCHAR(50)
|
||||
-- p_mcu_id INT
|
||||
-- ============================================================
|
||||
DROP PROCEDURE IF EXISTS sp_remove_user_project;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_remove_user_project(
|
||||
IN p_username VARCHAR(50),
|
||||
IN p_mcu_id INT
|
||||
)
|
||||
BEGIN
|
||||
DECLARE v_user_id INT;
|
||||
|
||||
SELECT User_ID INTO v_user_id
|
||||
FROM dashboard_user
|
||||
WHERE User_Username = p_username
|
||||
LIMIT 1;
|
||||
|
||||
IF v_user_id IS NULL THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'User tidak ditemukan';
|
||||
END IF;
|
||||
|
||||
UPDATE dashboard_user_project
|
||||
SET UserProj_IsActive = 'N',
|
||||
UserProj_UpdatedAt = NOW()
|
||||
WHERE UserProj_UserID = v_user_id
|
||||
AND UserProj_McuID = p_mcu_id;
|
||||
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
108
cpone-dashboard/db/migrations/007_sp_fix_dashboard_user.sql
Normal file
108
cpone-dashboard/db/migrations/007_sp_fix_dashboard_user.sql
Normal file
@@ -0,0 +1,108 @@
|
||||
-- Migration 007: pisah sp_upsert_dashboard_user menjadi insert dan update terpisah
|
||||
|
||||
DROP PROCEDURE IF EXISTS sp_upsert_dashboard_user;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- sp_insert_dashboard_user
|
||||
-- Hanya untuk membuat user BARU.
|
||||
-- Error jika username sudah terdaftar.
|
||||
-- Parameter:
|
||||
-- p_username VARCHAR(50)
|
||||
-- p_password VARCHAR(255) plain text, minimal 6 karakter
|
||||
-- p_display_name VARCHAR(100) boleh NULL
|
||||
-- ============================================================
|
||||
DROP PROCEDURE IF EXISTS sp_insert_dashboard_user;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_insert_dashboard_user(
|
||||
IN p_username VARCHAR(50),
|
||||
IN p_password VARCHAR(255),
|
||||
IN p_display_name VARCHAR(100)
|
||||
)
|
||||
BEGIN
|
||||
DECLARE v_salt VARCHAR(36);
|
||||
DECLARE v_hash VARCHAR(64);
|
||||
|
||||
IF p_username IS NULL OR TRIM(p_username) = '' THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'Username tidak boleh kosong';
|
||||
END IF;
|
||||
|
||||
IF p_password IS NULL OR TRIM(p_password) = '' THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'Password tidak boleh kosong';
|
||||
END IF;
|
||||
|
||||
IF LENGTH(p_password) < 6 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'Password minimal 6 karakter';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (SELECT 1 FROM dashboard_user WHERE User_Username = TRIM(p_username)) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'Username sudah terdaftar';
|
||||
END IF;
|
||||
|
||||
SET v_salt = UUID();
|
||||
SET v_hash = SHA2(CONCAT(v_salt, ':', p_password), 256);
|
||||
|
||||
INSERT INTO dashboard_user (User_Username, User_Password, User_Salt, User_DisplayName, User_IsActive)
|
||||
VALUES (TRIM(p_username), v_hash, v_salt, p_display_name, 'Y');
|
||||
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- sp_reset_dashboard_user_password
|
||||
-- Hanya untuk reset password user yang SUDAH ADA.
|
||||
-- Error jika username tidak ditemukan.
|
||||
-- Parameter:
|
||||
-- p_username VARCHAR(50)
|
||||
-- p_new_password VARCHAR(255) plain text, minimal 6 karakter
|
||||
-- ============================================================
|
||||
DROP PROCEDURE IF EXISTS sp_reset_dashboard_user_password;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_reset_dashboard_user_password(
|
||||
IN p_username VARCHAR(50),
|
||||
IN p_new_password VARCHAR(255)
|
||||
)
|
||||
BEGIN
|
||||
DECLARE v_salt VARCHAR(36);
|
||||
DECLARE v_hash VARCHAR(64);
|
||||
|
||||
IF p_new_password IS NULL OR TRIM(p_new_password) = '' THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'Password tidak boleh kosong';
|
||||
END IF;
|
||||
|
||||
IF LENGTH(p_new_password) < 6 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'Password minimal 6 karakter';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dashboard_user WHERE User_Username = TRIM(p_username)) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'User tidak ditemukan';
|
||||
END IF;
|
||||
|
||||
SET v_salt = UUID();
|
||||
SET v_hash = SHA2(CONCAT(v_salt, ':', p_new_password), 256);
|
||||
|
||||
UPDATE dashboard_user
|
||||
SET User_Password = v_hash,
|
||||
User_Salt = v_salt,
|
||||
User_UpdatedAt = NOW()
|
||||
WHERE User_Username = TRIM(p_username);
|
||||
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
|
||||
-- Tidak perlu re-seed, user admin sudah ada dari migration 005
|
||||
264
cpone-dashboard/db/migrations/008_sp_fix_checkin_date.sql
Normal file
264
cpone-dashboard/db/migrations/008_sp_fix_checkin_date.sql
Normal file
@@ -0,0 +1,264 @@
|
||||
-- Migration 008: fix Mcu_StationProgressCheckinDate di sp_generate_dashboard_data
|
||||
-- Sebelumnya pakai DATE(T_OrderHeaderDate) — tanggal order, bukan tanggal station dikerjakan.
|
||||
-- Seharusnya:
|
||||
-- lab → DATE(SamplingAt), fallback T_OrderHeaderDate
|
||||
-- nonlab → DATE(ProcessAt), fallback T_OrderHeaderDate
|
||||
|
||||
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
|
||||
BEGIN
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 1. mcu_project
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_project (
|
||||
Mcu_ProjectMcuID, Mcu_ProjectCorporateID, Mcu_ProjectCorporateName,
|
||||
Mcu_ProjectNumber, Mcu_ProjectLabel, Mcu_ProjectBranchID,
|
||||
Mcu_ProjectStartDate, Mcu_ProjectEndDate, Mcu_ProjectIsActive,
|
||||
Mcu_ProjectTotalParticipant, Mcu_ProjectSyncedAt
|
||||
)
|
||||
SELECT
|
||||
m.Mgm_McuID, m.Mgm_McuCorporateID, c.CorporateName,
|
||||
m.Mgm_McuNumber, m.Mgm_McuLabel, m.Mgm_McuM_BranchID,
|
||||
m.Mgm_McuStartDate, m.Mgm_McuEndDate, m.Mgm_McuIsActive,
|
||||
m.Mgm_McuTotalParticipant, NOW()
|
||||
FROM cpone.mgm_mcu m
|
||||
LEFT JOIN cpone.corporate c ON c.CorporateID = m.Mgm_McuCorporateID
|
||||
WHERE m.Mgm_McuID = p_mcu_id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_ProjectCorporateName = VALUES(Mcu_ProjectCorporateName),
|
||||
Mcu_ProjectLabel = VALUES(Mcu_ProjectLabel),
|
||||
Mcu_ProjectIsActive = VALUES(Mcu_ProjectIsActive),
|
||||
Mcu_ProjectTotalParticipant = VALUES(Mcu_ProjectTotalParticipant),
|
||||
Mcu_ProjectSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 2. mcu_patient
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_patient (
|
||||
Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName,
|
||||
Mcu_PatientNIP, Mcu_PatientGender, Mcu_PatientDOB,
|
||||
Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi,
|
||||
Mcu_PatientIsRegistered, Mcu_PatientOrderID, Mcu_PatientIsActive,
|
||||
Mcu_PatientSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p.Mcu_PreregisterPatientsID, p.Mcu_PreregisterPatientsMgm_McuID,
|
||||
p.Mcu_PreregisterPatientsPatientName, p.Mcu_PreregisterPatientsNIP,
|
||||
p.Mcu_PreregisterPatientsGender, p.Mcu_PreregisterPatientsDOB,
|
||||
p.Mcu_PreregisterPatientsDepartment, p.Mcu_PreregisterPatientsDivisi,
|
||||
p.Mcu_PreregisterPatientsPosisi,
|
||||
p.Mcu_PreregisterPatientsIsRegistered, p.Mcu_PreregisterPatientsT_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsIsActive, NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_PatientName = VALUES(Mcu_PatientName),
|
||||
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
|
||||
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
|
||||
Mcu_PatientSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 3. mcu_patient_schedule
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_patient_schedule (
|
||||
Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate,
|
||||
Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p.Mcu_PreregisterPatientsID, DATE(o.T_OrderHeaderDate), 'Y', NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_PatientScheduleSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 4. mcu_checkinout
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_checkinout (
|
||||
Mcu_CheckinoutMcuID, Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
|
||||
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
|
||||
Mcu_CheckinoutNextDate, Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p_mcu_id,
|
||||
src.preregister_id,
|
||||
src.order_id,
|
||||
src.segment_date,
|
||||
TIME(src.checkin_dt),
|
||||
TIME(src.checkout_dt),
|
||||
CASE
|
||||
WHEN DATE(src.checkout_dt) = src.segment_date THEN NULL
|
||||
ELSE DATE(src.checkout_dt)
|
||||
END,
|
||||
'Y', NOW()
|
||||
FROM (
|
||||
SELECT
|
||||
o.T_OrderHeaderID AS order_id,
|
||||
p.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o.T_OrderHeaderDate AS header_dt,
|
||||
ev.segment_date,
|
||||
CASE
|
||||
WHEN ev.segment_date = DATE(o.T_OrderHeaderDate) THEN o.T_OrderHeaderDate
|
||||
ELSE MIN(ev.event_dt)
|
||||
END AS checkin_dt,
|
||||
MAX(ev.event_dt) AS checkout_dt
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN (
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(o2.T_OrderHeaderDate) AS segment_date,
|
||||
o2.T_OrderHeaderDate AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
UNION ALL
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)) AS segment_date,
|
||||
TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime) AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_ordersample s2
|
||||
ON s2.T_OrderSampleT_OrderHeaderID = o2.T_OrderHeaderID
|
||||
AND s2.T_OrderSampleIsActive = 'Y'
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
AND s2.T_OrderSampleReceiveDate IS NOT NULL
|
||||
AND s2.T_OrderSampleReceiveTime IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)) AS segment_date,
|
||||
TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime) AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_samplingso ts2
|
||||
ON ts2.T_SamplingSoT_OrderHeaderID = o2.T_OrderHeaderID
|
||||
AND ts2.T_SamplingSoIsActive = 'Y'
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
AND ts2.T_SamplingSoDoneDate IS NOT NULL
|
||||
AND ts2.T_SamplingSoDoneTime IS NOT NULL
|
||||
) ev
|
||||
ON ev.preregister_id = p.Mcu_PreregisterPatientsID
|
||||
AND ev.order_id = o.T_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, o.T_OrderHeaderDate, ev.segment_date
|
||||
) AS src
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
|
||||
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
|
||||
Mcu_CheckinoutSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 5. mcu_station_progress — lab (dari t_ordersample)
|
||||
-- CheckinDate = DATE(SamplingAt), fallback DATE(T_OrderHeaderDate)
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_station_progress (
|
||||
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
|
||||
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
|
||||
Mcu_StationProgressCheckinDate,
|
||||
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
|
||||
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
|
||||
)
|
||||
SELECT
|
||||
o.T_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
p_mcu_id,
|
||||
s.T_OrderSampleT_SampleStationID,
|
||||
ss.T_SampleStationName,
|
||||
'lab',
|
||||
COALESCE(
|
||||
DATE(NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00')),
|
||||
DATE(o.T_OrderHeaderDate)
|
||||
),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleReceiveDate, s.T_OrderSampleReceiveTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleProcessingDate, s.T_OrderSampleProcessingTime)), '0000-00-00 00:00:00'),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_ordersample s ON s.T_OrderSampleT_OrderHeaderID = o.T_OrderHeaderID AND s.T_OrderSampleIsActive = 'Y'
|
||||
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = s.T_OrderSampleT_SampleStationID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_StationProgressCheckinDate = VALUES(Mcu_StationProgressCheckinDate),
|
||||
Mcu_StationProgressSamplingAt = VALUES(Mcu_StationProgressSamplingAt),
|
||||
Mcu_StationProgressReceiveAt = VALUES(Mcu_StationProgressReceiveAt),
|
||||
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
|
||||
Mcu_StationProgressSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 6. mcu_station_progress — nonlab (dari t_samplingso)
|
||||
-- CheckinDate = DATE(ProcessAt), fallback DATE(T_OrderHeaderDate)
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_station_progress (
|
||||
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
|
||||
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
|
||||
Mcu_StationProgressCheckinDate,
|
||||
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
|
||||
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
|
||||
)
|
||||
SELECT
|
||||
o.T_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
p_mcu_id,
|
||||
ts.T_SamplingSoT_SampleStationID,
|
||||
ss.T_SampleStationName,
|
||||
'nonlab',
|
||||
COALESCE(
|
||||
DATE(NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00')),
|
||||
DATE(o.T_OrderHeaderDate)
|
||||
),
|
||||
NULL,
|
||||
NULL,
|
||||
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoDoneDate, ts.T_SamplingSoDoneTime)), '0000-00-00 00:00:00'),
|
||||
NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_samplingso ts ON ts.T_SamplingSoT_OrderHeaderID = o.T_OrderHeaderID AND ts.T_SamplingSoIsActive = 'Y'
|
||||
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = ts.T_SamplingSoT_SampleStationID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_StationProgressCheckinDate = VALUES(Mcu_StationProgressCheckinDate),
|
||||
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
|
||||
Mcu_StationProgressDoneAt = VALUES(Mcu_StationProgressDoneAt),
|
||||
Mcu_StationProgressSyncedAt = NOW();
|
||||
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
13
cpone-dashboard/db/migrations/009_checkinout_add_mcu_id.sql
Normal file
13
cpone-dashboard/db/migrations/009_checkinout_add_mcu_id.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Migration 009: ganti Mcu_CheckinoutCheckinoutID dengan Mcu_CheckinoutMcuID
|
||||
-- Kolom lama (CheckinoutCheckinoutID) tidak dipakai di aplikasi dan di SP
|
||||
-- diisi dengan order_id yang salah. Ganti dengan mcu_id supaya query lebih simpel.
|
||||
|
||||
ALTER TABLE mcu_checkinout
|
||||
DROP COLUMN Mcu_CheckinoutCheckinoutID,
|
||||
ADD COLUMN Mcu_CheckinoutMcuID INT NOT NULL DEFAULT 0 AFTER Mcu_CheckinoutID,
|
||||
ADD INDEX idx_mcu_id (Mcu_CheckinoutMcuID);
|
||||
|
||||
-- Isi Mcu_CheckinoutMcuID dari mcu_patient untuk data yang sudah ada
|
||||
UPDATE mcu_checkinout mc
|
||||
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = mc.Mcu_CheckinoutPreregisterID
|
||||
SET mc.Mcu_CheckinoutMcuID = mp.Mcu_PatientMcuID;
|
||||
259
cpone-dashboard/db/migrations/010_patient_add_age.sql
Normal file
259
cpone-dashboard/db/migrations/010_patient_add_age.sql
Normal file
@@ -0,0 +1,259 @@
|
||||
-- Migration 010: Add age column to mcu_patient and update SP
|
||||
|
||||
ALTER TABLE mcu_patient
|
||||
ADD COLUMN Mcu_PatientAge INT DEFAULT NULL AFTER Mcu_PatientDOB;
|
||||
|
||||
-- Update SP: tambah age dari T_OrderHeader.M_PatientAge
|
||||
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
|
||||
BEGIN
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 1. mcu_project
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_project (
|
||||
Mcu_ProjectMcuID, Mcu_ProjectCorporateID, Mcu_ProjectCorporateName,
|
||||
Mcu_ProjectNumber, Mcu_ProjectLabel, Mcu_ProjectBranchID,
|
||||
Mcu_ProjectStartDate, Mcu_ProjectEndDate, Mcu_ProjectIsActive,
|
||||
Mcu_ProjectTotalParticipant, Mcu_ProjectSyncedAt
|
||||
)
|
||||
SELECT
|
||||
m.Mgm_McuID, m.Mgm_McuCorporateID, c.CorporateName,
|
||||
m.Mgm_McuNumber, m.Mgm_McuLabel, m.Mgm_McuM_BranchID,
|
||||
m.Mgm_McuStartDate, m.Mgm_McuEndDate, m.Mgm_McuIsActive,
|
||||
m.Mgm_McuTotalParticipant, NOW()
|
||||
FROM cpone.mgm_mcu m
|
||||
LEFT JOIN cpone.corporate c ON c.CorporateID = m.Mgm_McuCorporateID
|
||||
WHERE m.Mgm_McuID = p_mcu_id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_ProjectCorporateName = VALUES(Mcu_ProjectCorporateName),
|
||||
Mcu_ProjectLabel = VALUES(Mcu_ProjectLabel),
|
||||
Mcu_ProjectIsActive = VALUES(Mcu_ProjectIsActive),
|
||||
Mcu_ProjectTotalParticipant = VALUES(Mcu_ProjectTotalParticipant),
|
||||
Mcu_ProjectSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 2. mcu_patient (with age)
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_patient (
|
||||
Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName,
|
||||
Mcu_PatientNIP, Mcu_PatientGender, Mcu_PatientDOB, Mcu_PatientAge,
|
||||
Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi,
|
||||
Mcu_PatientIsRegistered, Mcu_PatientOrderID, Mcu_PatientIsActive,
|
||||
Mcu_PatientSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p.Mcu_PreregisterPatientsID, p.Mcu_PreregisterPatientsMgm_McuID,
|
||||
p.Mcu_PreregisterPatientsPatientName, p.Mcu_PreregisterPatientsNIP,
|
||||
p.Mcu_PreregisterPatientsGender, p.Mcu_PreregisterPatientsDOB,
|
||||
o.T_OrderHeaderM_PatientAge,
|
||||
p.Mcu_PreregisterPatientsDepartment, p.Mcu_PreregisterPatientsDivisi,
|
||||
p.Mcu_PreregisterPatientsPosisi,
|
||||
p.Mcu_PreregisterPatientsIsRegistered, p.Mcu_PreregisterPatientsT_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsIsActive, NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
LEFT JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_PatientName = VALUES(Mcu_PatientName),
|
||||
Mcu_PatientAge = VALUES(Mcu_PatientAge),
|
||||
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
|
||||
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
|
||||
Mcu_PatientSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 3. mcu_patient_schedule
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_patient_schedule (
|
||||
Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate,
|
||||
Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p.Mcu_PreregisterPatientsID, DATE(o.T_OrderHeaderDate), 'Y', NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_PatientScheduleSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 4. mcu_checkinout
|
||||
-- Satu baris per tanggal proses.
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_checkinout (
|
||||
Mcu_CheckinoutCheckinoutID, Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
|
||||
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
|
||||
Mcu_CheckinoutNextDate, Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt, Mcu_CheckinoutMcuID
|
||||
)
|
||||
SELECT
|
||||
src.order_id,
|
||||
src.preregister_id,
|
||||
src.order_id,
|
||||
src.segment_date,
|
||||
TIME(src.checkin_dt),
|
||||
TIME(src.checkout_dt),
|
||||
CASE
|
||||
WHEN DATE(src.checkout_dt) = src.segment_date THEN NULL
|
||||
ELSE DATE(src.checkout_dt)
|
||||
END,
|
||||
'Y', NOW(), p_mcu_id
|
||||
FROM (
|
||||
SELECT
|
||||
o.T_OrderHeaderID AS order_id,
|
||||
p.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o.T_OrderHeaderDate AS header_dt,
|
||||
ev.segment_date,
|
||||
CASE
|
||||
WHEN ev.segment_date = DATE(o.T_OrderHeaderDate) THEN o.T_OrderHeaderDate
|
||||
ELSE MIN(ev.event_dt)
|
||||
END AS checkin_dt,
|
||||
MAX(ev.event_dt) AS checkout_dt
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN (
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(o2.T_OrderHeaderDate) AS segment_date,
|
||||
o2.T_OrderHeaderDate AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
UNION ALL
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)) AS segment_date,
|
||||
TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime) AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_ordersample s2
|
||||
ON s2.T_OrderSampleT_OrderHeaderID = o2.T_OrderHeaderID
|
||||
AND s2.T_OrderSampleIsActive = 'Y'
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
AND s2.T_OrderSampleReceiveDate IS NOT NULL
|
||||
AND s2.T_OrderSampleReceiveTime IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)) AS segment_date,
|
||||
TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime) AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_samplingso ts2
|
||||
ON ts2.T_SamplingSoT_OrderHeaderID = o2.T_OrderHeaderID
|
||||
AND ts2.T_SamplingSoIsActive = 'Y'
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
AND ts2.T_SamplingSoDoneDate IS NOT NULL
|
||||
AND ts2.T_SamplingSoDoneTime IS NOT NULL
|
||||
) ev
|
||||
ON ev.preregister_id = p.Mcu_PreregisterPatientsID
|
||||
AND ev.order_id = o.T_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, o.T_OrderHeaderDate, ev.segment_date
|
||||
) AS src
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
|
||||
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
|
||||
Mcu_CheckinoutSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 5. mcu_station_progress — lab (dari t_ordersample)
|
||||
-- GROUP BY order+station supaya satu baris per pasien per station
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_station_progress (
|
||||
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
|
||||
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
|
||||
Mcu_StationProgressCheckinDate,
|
||||
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
|
||||
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
|
||||
)
|
||||
SELECT
|
||||
o.T_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
p_mcu_id,
|
||||
s.T_OrderSampleT_SampleStationID,
|
||||
ss.T_SampleStationName,
|
||||
'lab',
|
||||
DATE(o.T_OrderHeaderDate),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleReceiveDate, s.T_OrderSampleReceiveTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleProcessingDate, s.T_OrderSampleProcessingTime)), '0000-00-00 00:00:00'),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_ordersample s ON s.T_OrderSampleT_OrderHeaderID = o.T_OrderHeaderID AND s.T_OrderSampleIsActive = 'Y'
|
||||
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = s.T_OrderSampleT_SampleStationID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_StationProgressSamplingAt = VALUES(Mcu_StationProgressSamplingAt),
|
||||
Mcu_StationProgressReceiveAt = VALUES(Mcu_StationProgressReceiveAt),
|
||||
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
|
||||
Mcu_StationProgressSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 6. mcu_station_progress — nonlab (dari t_samplingso)
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_station_progress (
|
||||
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
|
||||
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
|
||||
Mcu_StationProgressCheckinDate,
|
||||
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
|
||||
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
|
||||
)
|
||||
SELECT
|
||||
o.T_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
p_mcu_id,
|
||||
ts.T_SamplingSoT_SampleStationID,
|
||||
ss.T_SampleStationName,
|
||||
'nonlab',
|
||||
DATE(o.T_OrderHeaderDate),
|
||||
NULL,
|
||||
NULL,
|
||||
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoDoneDate, ts.T_SamplingSoDoneTime)), '0000-00-00 00:00:00'),
|
||||
NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_samplingso ts ON ts.T_SamplingSoT_OrderHeaderID = o.T_OrderHeaderID AND ts.T_SamplingSoIsActive = 'Y'
|
||||
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = ts.T_SamplingSoT_SampleStationID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
|
||||
Mcu_StationProgressDoneAt = VALUES(Mcu_StationProgressDoneAt),
|
||||
Mcu_StationProgressSyncedAt = NOW();
|
||||
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
274
cpone-dashboard/db/migrations/011_patient_resume_status.sql
Normal file
274
cpone-dashboard/db/migrations/011_patient_resume_status.sql
Normal file
@@ -0,0 +1,274 @@
|
||||
-- Migration 011: Add mcu_patient_resume_status table
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mcu_patient_resume_status (
|
||||
Mcu_PatientResumeStatusID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Mcu_PatientResumeStatusPreregisterID INT NOT NULL,
|
||||
Mcu_PatientResumeStatusMcuID INT NOT NULL,
|
||||
Mcu_PatientResumeStatusStatus VARCHAR(15), -- from mcu_resume.Mcu_ResumeStatus
|
||||
Mcu_PatientResumeStatusValidated CHAR(1) DEFAULT 'N', -- from mcu_resume.Mcu_ResumeValidation
|
||||
Mcu_PatientResumeStatusPublished CHAR(1) DEFAULT 'N', -- default N
|
||||
Mcu_PatientResumeSyncedAt DATETIME,
|
||||
UNIQUE KEY uq_patient_mcu (Mcu_PatientResumeStatusPreregisterID, Mcu_PatientResumeStatusMcuID),
|
||||
INDEX idx_mcu_id (Mcu_PatientResumeStatusMcuID),
|
||||
INDEX idx_preregister_id (Mcu_PatientResumeStatusPreregisterID),
|
||||
INDEX idx_validated (Mcu_PatientResumeStatusValidated),
|
||||
INDEX idx_published (Mcu_PatientResumeStatusPublished)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- Update SP: tambah step 7 untuk mcu_patient_resume_status
|
||||
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
|
||||
BEGIN
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 1. mcu_patient (with age)
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_patient (
|
||||
Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName,
|
||||
Mcu_PatientNIP, Mcu_PatientGender, Mcu_PatientDOB, Mcu_PatientAge,
|
||||
Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi,
|
||||
Mcu_PatientIsRegistered, Mcu_PatientOrderID, Mcu_PatientIsActive,
|
||||
Mcu_PatientSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p.Mcu_PreregisterPatientsID, p.Mcu_PreregisterPatientsMgm_McuID,
|
||||
p.Mcu_PreregisterPatientsPatientName, p.Mcu_PreregisterPatientsNIP,
|
||||
p.Mcu_PreregisterPatientsGender, p.Mcu_PreregisterPatientsDOB,
|
||||
CAST(o.T_OrderHeaderM_PatientAge AS UNSIGNED),
|
||||
p.Mcu_PreregisterPatientsDepartment, p.Mcu_PreregisterPatientsDivisi,
|
||||
p.Mcu_PreregisterPatientsPosisi,
|
||||
p.Mcu_PreregisterPatientsIsRegistered, p.Mcu_PreregisterPatientsT_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsIsActive, NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
LEFT JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_PatientName = VALUES(Mcu_PatientName),
|
||||
Mcu_PatientAge = VALUES(Mcu_PatientAge),
|
||||
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
|
||||
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
|
||||
Mcu_PatientSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 2. mcu_patient_schedule
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_patient_schedule (
|
||||
Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate,
|
||||
Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p.Mcu_PreregisterPatientsID, DATE(o.T_OrderHeaderDate), 'Y', NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_PatientScheduleSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 3. mcu_checkinout
|
||||
-- Satu baris per tanggal proses.
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_checkinout (
|
||||
Mcu_CheckinoutCheckinoutID, Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
|
||||
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
|
||||
Mcu_CheckinoutNextDate, Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt, Mcu_CheckinoutMcuID
|
||||
)
|
||||
SELECT
|
||||
src.order_id,
|
||||
src.preregister_id,
|
||||
src.order_id,
|
||||
src.segment_date,
|
||||
TIME(src.checkin_dt),
|
||||
TIME(src.checkout_dt),
|
||||
CASE
|
||||
WHEN DATE(src.checkout_dt) = src.segment_date THEN NULL
|
||||
ELSE DATE(src.checkout_dt)
|
||||
END,
|
||||
'Y', NOW(), p_mcu_id
|
||||
FROM (
|
||||
SELECT
|
||||
o.T_OrderHeaderID AS order_id,
|
||||
p.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o.T_OrderHeaderDate AS header_dt,
|
||||
ev.segment_date,
|
||||
CASE
|
||||
WHEN ev.segment_date = DATE(o.T_OrderHeaderDate) THEN o.T_OrderHeaderDate
|
||||
ELSE MIN(ev.event_dt)
|
||||
END AS checkin_dt,
|
||||
MAX(ev.event_dt) AS checkout_dt
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN (
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(o2.T_OrderHeaderDate) AS segment_date,
|
||||
o2.T_OrderHeaderDate AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
UNION ALL
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)) AS segment_date,
|
||||
TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime) AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_ordersample s2
|
||||
ON s2.T_OrderSampleT_OrderHeaderID = o2.T_OrderHeaderID
|
||||
AND s2.T_OrderSampleIsActive = 'Y'
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
AND s2.T_OrderSampleReceiveDate IS NOT NULL
|
||||
AND s2.T_OrderSampleReceiveTime IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
p2.Mcu_PreregisterPatientsID AS preregister_id,
|
||||
o2.T_OrderHeaderID AS order_id,
|
||||
o2.T_OrderHeaderDate AS checkin_dt,
|
||||
DATE(TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)) AS segment_date,
|
||||
TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime) AS event_dt
|
||||
FROM cpone.mcu_preregister_patients p2
|
||||
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_samplingso ts2
|
||||
ON ts2.T_SamplingSoT_OrderHeaderID = o2.T_OrderHeaderID
|
||||
AND ts2.T_SamplingSoIsActive = 'Y'
|
||||
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
AND ts2.T_SamplingSoDoneDate IS NOT NULL
|
||||
AND ts2.T_SamplingSoDoneTime IS NOT NULL
|
||||
) ev
|
||||
ON ev.preregister_id = p.Mcu_PreregisterPatientsID
|
||||
AND ev.order_id = o.T_OrderHeaderID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, o.T_OrderHeaderDate, ev.segment_date
|
||||
) AS src
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
|
||||
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
|
||||
Mcu_CheckinoutSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 4. mcu_station_progress — lab (dari t_ordersample)
|
||||
-- GROUP BY order+station supaya satu baris per pasien per station
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_station_progress (
|
||||
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
|
||||
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
|
||||
Mcu_StationProgressCheckinDate,
|
||||
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
|
||||
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
|
||||
)
|
||||
SELECT
|
||||
o.T_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
p_mcu_id,
|
||||
s.T_OrderSampleT_SampleStationID,
|
||||
ss.T_SampleStationName,
|
||||
'lab',
|
||||
DATE(o.T_OrderHeaderDate),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleReceiveDate, s.T_OrderSampleReceiveTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleProcessingDate, s.T_OrderSampleProcessingTime)), '0000-00-00 00:00:00'),
|
||||
NULL,
|
||||
NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_ordersample s ON s.T_OrderSampleT_OrderHeaderID = o.T_OrderHeaderID AND s.T_OrderSampleIsActive = 'Y'
|
||||
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = s.T_OrderSampleT_SampleStationID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_StationProgressSamplingAt = VALUES(Mcu_StationProgressSamplingAt),
|
||||
Mcu_StationProgressReceiveAt = VALUES(Mcu_StationProgressReceiveAt),
|
||||
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
|
||||
Mcu_StationProgressSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 5. mcu_station_progress — nonlab (dari t_samplingso)
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_station_progress (
|
||||
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
|
||||
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
|
||||
Mcu_StationProgressCheckinDate,
|
||||
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
|
||||
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
|
||||
)
|
||||
SELECT
|
||||
o.T_OrderHeaderID,
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
p_mcu_id,
|
||||
ts.T_SamplingSoT_SampleStationID,
|
||||
ss.T_SampleStationName,
|
||||
'nonlab',
|
||||
DATE(o.T_OrderHeaderDate),
|
||||
NULL,
|
||||
NULL,
|
||||
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00'),
|
||||
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoDoneDate, ts.T_SamplingSoDoneTime)), '0000-00-00 00:00:00'),
|
||||
NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
JOIN cpone.t_samplingso ts ON ts.T_SamplingSoT_OrderHeaderID = o.T_OrderHeaderID AND ts.T_SamplingSoIsActive = 'Y'
|
||||
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = ts.T_SamplingSoT_SampleStationID
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
|
||||
Mcu_StationProgressDoneAt = VALUES(Mcu_StationProgressDoneAt),
|
||||
Mcu_StationProgressSyncedAt = NOW();
|
||||
|
||||
|
||||
-- ----------------------------------------------------------
|
||||
-- 6. mcu_patient_resume_status (dari mcu_resume)
|
||||
-- ----------------------------------------------------------
|
||||
INSERT INTO mcu_patient_resume_status (
|
||||
Mcu_PatientResumeStatusPreregisterID, Mcu_PatientResumeStatusMcuID,
|
||||
Mcu_PatientResumeStatusStatus, Mcu_PatientResumeStatusValidated,
|
||||
Mcu_PatientResumeStatusPublished, Mcu_PatientResumeSyncedAt
|
||||
)
|
||||
SELECT
|
||||
p.Mcu_PreregisterPatientsID,
|
||||
p_mcu_id,
|
||||
r.Mcu_ResumeStatus,
|
||||
r.Mcu_ResumeValidation,
|
||||
'N',
|
||||
NOW()
|
||||
FROM cpone.mcu_preregister_patients p
|
||||
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
|
||||
LEFT JOIN cpone.mcu_resume r ON r.Mcu_ResumeT_OrderHeaderID = o.T_OrderHeaderID AND r.Mcu_ResumeIsActive = 'Y'
|
||||
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
|
||||
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
|
||||
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_PatientResumeStatusStatus = VALUES(Mcu_PatientResumeStatusStatus),
|
||||
Mcu_PatientResumeStatusValidated = VALUES(Mcu_PatientResumeStatusValidated),
|
||||
Mcu_PatientResumeSyncedAt = NOW();
|
||||
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
11
cpone-dashboard/go.mod
Normal file
11
cpone-dashboard/go.mod
Normal file
@@ -0,0 +1,11 @@
|
||||
module cpone-dashboard
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
)
|
||||
|
||||
require filippo.io/edwards25519 v1.1.0 // indirect
|
||||
8
cpone-dashboard/go.sum
Normal file
8
cpone-dashboard/go.sum
Normal file
@@ -0,0 +1,8 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
310
cpone-dashboard/main.go
Normal file
310
cpone-dashboard/main.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cpone-dashboard/config"
|
||||
"cpone-dashboard/db"
|
||||
"cpone-dashboard/menu/abnormal"
|
||||
"cpone-dashboard/menu/arrival"
|
||||
"cpone-dashboard/menu/auth"
|
||||
"cpone-dashboard/menu/dashboard"
|
||||
"cpone-dashboard/menu/progress"
|
||||
"cpone-dashboard/menu/projects"
|
||||
"cpone-dashboard/menu/result"
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var templateFS embed.FS
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
if err := db.Connect(cfg.DBDSN); err != nil {
|
||||
log.Fatalf("db connect: %v", err)
|
||||
}
|
||||
defer db.DB.Close()
|
||||
|
||||
dashboard.SetTemplateFuncs(template.FuncMap{
|
||||
"div": func(a, b int) int {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return a / b
|
||||
},
|
||||
"mod": func(a, b int) int { return a % b },
|
||||
"pct": func(a, b int) float64 {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(a) / float64(b) * 100
|
||||
},
|
||||
"stationShort": func(s string) string {
|
||||
s = strings.TrimPrefix(s, "Sample Station ")
|
||||
s = strings.TrimPrefix(s, "sample station ")
|
||||
return s
|
||||
},
|
||||
"fmtDate": func(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
layouts := []string{
|
||||
"2006-01-02",
|
||||
"2006-01-02 15:04:05",
|
||||
time.RFC3339,
|
||||
"02/01/2006",
|
||||
"02/01/2006 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
|
||||
return t.Format("02/01/2006")
|
||||
}
|
||||
}
|
||||
if len(s) >= 10 {
|
||||
return s[8:10] + "/" + s[5:7] + "/" + s[0:4]
|
||||
}
|
||||
return s
|
||||
},
|
||||
"fmtDateTime": func(dateStr, timeStr string) string {
|
||||
if dateStr == "" {
|
||||
return ""
|
||||
}
|
||||
dateLayouts := []string{
|
||||
"2006-01-02",
|
||||
"2006-01-02 15:04:05",
|
||||
"02/01/2006",
|
||||
"02/01/2006 15:04:05",
|
||||
time.RFC3339,
|
||||
}
|
||||
var datePart time.Time
|
||||
for _, layout := range dateLayouts {
|
||||
if t, err := time.ParseInLocation(layout, dateStr, time.Local); err == nil {
|
||||
datePart = t
|
||||
break
|
||||
}
|
||||
}
|
||||
if datePart.IsZero() {
|
||||
if len(dateStr) >= 10 {
|
||||
dateStr = dateStr[8:10] + "/" + dateStr[5:7] + "/" + dateStr[0:4]
|
||||
}
|
||||
} else {
|
||||
dateStr = datePart.Format("02/01/2006")
|
||||
}
|
||||
if timeStr == "" {
|
||||
return dateStr
|
||||
}
|
||||
timeLayouts := []string{"15:04:05", "15:04"}
|
||||
var timePart string
|
||||
for _, layout := range timeLayouts {
|
||||
if t, err := time.Parse(layout, timeStr); err == nil {
|
||||
timePart = t.Format("15:04:05")
|
||||
break
|
||||
}
|
||||
}
|
||||
if timePart == "" {
|
||||
timePart = timeStr
|
||||
}
|
||||
return dateStr + " " + timePart
|
||||
},
|
||||
"initials": func(name string) string {
|
||||
parts := strings.Fields(name)
|
||||
if len(parts) == 0 {
|
||||
return "?"
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return strings.ToUpper(string([]rune(parts[0])[:1]))
|
||||
}
|
||||
return strings.ToUpper(string([]rune(parts[0])[:1]) + string([]rune(parts[len(parts)-1])[:1]))
|
||||
},
|
||||
"slice": func(args ...int) []int { return args },
|
||||
"seq": func(n int) []int {
|
||||
s := make([]int, n)
|
||||
for i := range s {
|
||||
s[i] = i
|
||||
}
|
||||
return s
|
||||
},
|
||||
})
|
||||
|
||||
auth.Init(&templateFS, cfg.AuthSecret)
|
||||
projects.SetTemplateFS(&templateFS)
|
||||
|
||||
pageFuncs := template.FuncMap{
|
||||
"div": func(a, b int) int {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return a / b
|
||||
},
|
||||
"mod": func(a, b int) int { return a % b },
|
||||
"pct": func(a, b int) float64 {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(a) / float64(b) * 100
|
||||
},
|
||||
"stationShort": func(s string) string {
|
||||
s = strings.TrimPrefix(s, "Sample Station ")
|
||||
s = strings.TrimPrefix(s, "sample station ")
|
||||
return s
|
||||
},
|
||||
"fmtDate": func(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
layouts := []string{
|
||||
"2006-01-02",
|
||||
"2006-01-02 15:04:05",
|
||||
time.RFC3339,
|
||||
"02/01/2006",
|
||||
"02/01/2006 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
|
||||
return t.Format("02/01/2006")
|
||||
}
|
||||
}
|
||||
if len(s) >= 10 {
|
||||
return s[8:10] + "/" + s[5:7] + "/" + s[0:4]
|
||||
}
|
||||
return s
|
||||
},
|
||||
"fmtDateTime": func(dateStr, timeStr string) string {
|
||||
if dateStr == "" {
|
||||
return ""
|
||||
}
|
||||
dateLayouts := []string{
|
||||
"2006-01-02",
|
||||
"2006-01-02 15:04:05",
|
||||
"02/01/2006",
|
||||
"02/01/2006 15:04:05",
|
||||
time.RFC3339,
|
||||
}
|
||||
var datePart time.Time
|
||||
for _, layout := range dateLayouts {
|
||||
if t, err := time.ParseInLocation(layout, dateStr, time.Local); err == nil {
|
||||
datePart = t
|
||||
break
|
||||
}
|
||||
}
|
||||
if datePart.IsZero() {
|
||||
if len(dateStr) >= 10 {
|
||||
dateStr = dateStr[8:10] + "/" + dateStr[5:7] + "/" + dateStr[0:4]
|
||||
}
|
||||
} else {
|
||||
dateStr = datePart.Format("02/01/2006")
|
||||
}
|
||||
if timeStr == "" {
|
||||
return dateStr
|
||||
}
|
||||
timeLayouts := []string{"15:04:05", "15:04"}
|
||||
var timePart string
|
||||
for _, layout := range timeLayouts {
|
||||
if t, err := time.Parse(layout, timeStr); err == nil {
|
||||
timePart = t.Format("15:04:05")
|
||||
break
|
||||
}
|
||||
}
|
||||
if timePart == "" {
|
||||
timePart = timeStr
|
||||
}
|
||||
return dateStr + " " + timePart
|
||||
},
|
||||
"initials": func(name string) string {
|
||||
parts := strings.Fields(name)
|
||||
if len(parts) == 0 {
|
||||
return "?"
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return strings.ToUpper(string([]rune(parts[0])[:1]))
|
||||
}
|
||||
return strings.ToUpper(string([]rune(parts[0])[:1]) + string([]rune(parts[len(parts)-1])[:1]))
|
||||
},
|
||||
"slice": func(args ...int) []int { return args },
|
||||
"seq": func(n int) []int {
|
||||
s := make([]int, n)
|
||||
for i := range s {
|
||||
s[i] = i
|
||||
}
|
||||
return s
|
||||
},
|
||||
}
|
||||
|
||||
bp := cfg.BasePath // e.g. "/cpone-dashboard" or ""
|
||||
pageFuncs["b"] = func(path string) string { return bp + path }
|
||||
dashboard.SetTemplateFuncs(pageFuncs)
|
||||
|
||||
newPageTmpl := func(files ...string) *template.Template {
|
||||
paths := append([]string{"templates/layout/base.html"}, files...)
|
||||
return template.Must(template.New("").Funcs(pageFuncs).ParseFS(templateFS, paths...))
|
||||
}
|
||||
|
||||
// Propagate basePath to all packages that redirect
|
||||
auth.SetBasePath(bp)
|
||||
dashboard.SetBasePath(bp)
|
||||
arrival.SetBasePath(bp)
|
||||
progress.SetBasePath(bp)
|
||||
abnormal.SetBasePath(bp)
|
||||
result.SetBasePath(bp)
|
||||
projects.SetBasePath(bp)
|
||||
|
||||
// Dashboard pakai templateFS langsung (parse per-handler)
|
||||
dashboard.SetTemplateFS(&templateFS)
|
||||
arrival.SetTemplates(newPageTmpl("templates/arrival/index.html"))
|
||||
progress.SetTemplates(newPageTmpl("templates/progress/index.html"))
|
||||
abnormal.SetTemplates(newPageTmpl("templates/abnormal/index.html"))
|
||||
result.SetTemplates(newPageTmpl("templates/result/index.html"))
|
||||
result.SetPDFBaseURL(cfg.PDFBaseURL)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
// Static files — always mounted at bp+/static/
|
||||
staticSub, _ := fs.Sub(staticFS, "static")
|
||||
staticPrefix := bp + "/static"
|
||||
r.Handle(staticPrefix+"/*", http.StripPrefix(staticPrefix+"/", http.FileServer(http.FS(staticSub))))
|
||||
|
||||
registerRoutes := func(r chi.Router) {
|
||||
auth.Routes(r)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.Require(cfg.AuthSecret))
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, bp+"/projects", http.StatusFound)
|
||||
})
|
||||
auth.ProtectedRoutes(r)
|
||||
r.Route("/projects", func(r chi.Router) { projects.Routes(r) })
|
||||
r.Route("/dashboard", func(r chi.Router) { dashboard.Routes(r) })
|
||||
r.Route("/arrival", func(r chi.Router) { arrival.Routes(r) })
|
||||
r.Route("/progress", func(r chi.Router) { progress.Routes(r) })
|
||||
r.Route("/abnormal", func(r chi.Router) { abnormal.Routes(r) })
|
||||
r.Route("/result", func(r chi.Router) { result.Routes(r) })
|
||||
})
|
||||
}
|
||||
|
||||
if bp == "" {
|
||||
registerRoutes(r)
|
||||
} else {
|
||||
r.Route(bp, registerRoutes)
|
||||
// redirect bare /cpone-dashboard → /cpone-dashboard/
|
||||
r.Get(bp, func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, bp+"/", http.StatusMovedPermanently)
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("server running on :%s (base path: %q)", cfg.AppPort, bp)
|
||||
if err := http.ListenAndServe(":"+cfg.AppPort, r); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
125
cpone-dashboard/menu/abnormal/handler.go
Normal file
125
cpone-dashboard/menu/abnormal/handler.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package abnormal
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"cpone-dashboard/menu/projects"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var tmpl *template.Template
|
||||
var basePath string
|
||||
|
||||
func SetTemplates(t *template.Template) { tmpl = t }
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type pageData struct {
|
||||
Username string
|
||||
CurrentProject projects.ProjectItem
|
||||
Group string
|
||||
Groups []string
|
||||
Summary AbnormalSummary
|
||||
StaffJSON template.JS
|
||||
AgeJSON template.JS
|
||||
GenderJSON template.JS
|
||||
DeptJSON template.JS
|
||||
}
|
||||
|
||||
func toJS(v any) template.JS {
|
||||
b, _ := json.Marshal(v)
|
||||
return template.JS(b)
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
mcuID := auth.SelectedProjectID(r)
|
||||
if mcuID == 0 {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
project, ok, err := projects.GetUserProject(username, mcuID)
|
||||
if err != nil || !ok {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
group := r.URL.Query().Get("group")
|
||||
|
||||
groups, err := GetAbnormalGroups(mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
total, err := GetTotalPatients(mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
abnormalCount, err := GetAbnormalCount(mcuID, group)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
normalCount := total - abnormalCount
|
||||
if normalCount < 0 {
|
||||
normalCount = 0
|
||||
}
|
||||
rate := 0
|
||||
if total > 0 {
|
||||
rate = abnormalCount * 100 / total
|
||||
}
|
||||
|
||||
ageData, err := GetAgeChartData(mcuID, group)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
genderData, err := GetGenderChartData(mcuID, group)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
deptData, err := GetDeptChartData(mcuID, group)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
t := tmpl
|
||||
if t == nil {
|
||||
http.Error(w, "template not ready", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "base", pageData{
|
||||
Username: username,
|
||||
CurrentProject: project,
|
||||
Group: group,
|
||||
Groups: groups,
|
||||
Summary: AbnormalSummary{
|
||||
Total: total,
|
||||
Abnormal: abnormalCount,
|
||||
Normal: normalCount,
|
||||
AbnormalRate: rate,
|
||||
},
|
||||
StaffJSON: toJS(map[string]any{
|
||||
"normal": normalCount,
|
||||
"abnormal": abnormalCount,
|
||||
}),
|
||||
AgeJSON: toJS(map[string]any{
|
||||
"labels": ageData.Labels,
|
||||
"abnormal": ageData.Abnormal,
|
||||
}),
|
||||
GenderJSON: toJS(map[string]any{
|
||||
"labels": genderData.Labels,
|
||||
"abnormal": genderData.Abnormal,
|
||||
}),
|
||||
DeptJSON: toJS(map[string]any{
|
||||
"labels": deptData.Labels,
|
||||
"abnormal": deptData.Abnormal,
|
||||
}),
|
||||
}); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
257
cpone-dashboard/menu/abnormal/query.go
Normal file
257
cpone-dashboard/menu/abnormal/query.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package abnormal
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
)
|
||||
|
||||
type AbnormalSummary struct {
|
||||
Total int
|
||||
Abnormal int
|
||||
Normal int
|
||||
AbnormalRate int // percentage, 0-100
|
||||
}
|
||||
|
||||
type AgeChartData struct {
|
||||
Labels []string
|
||||
Abnormal []int
|
||||
}
|
||||
|
||||
type GenderChartData struct {
|
||||
Labels []string
|
||||
Abnormal []int
|
||||
}
|
||||
|
||||
type DeptChartData struct {
|
||||
Labels []string
|
||||
Abnormal []int
|
||||
}
|
||||
|
||||
// GetTotalPatients returns total active patients for the project.
|
||||
func GetTotalPatients(mcuID int) (int, error) {
|
||||
var total int
|
||||
err := db.DB.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM mcu_patient
|
||||
WHERE Mcu_PatientMcuID = ?
|
||||
AND Mcu_PatientIsActive = 'Y'
|
||||
`, mcuID).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
// GetAbnormalCount returns distinct patients with at least one kelainan.
|
||||
// If group is non-empty, counts only patients with kelainan in that group.
|
||||
func GetAbnormalCount(mcuID int, group string) (int, error) {
|
||||
var count int
|
||||
var err error
|
||||
if group == "" {
|
||||
err = db.DB.QueryRow(`
|
||||
SELECT COUNT(DISTINCT M_PatientID)
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
`, mcuID).Scan(&count)
|
||||
} else {
|
||||
err = db.DB.QueryRow(`
|
||||
SELECT COUNT(DISTINCT M_PatientID)
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND Mcu_KelainanGroupSummaryName = ?
|
||||
`, mcuID, group).Scan(&count)
|
||||
}
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetAbnormalGroups returns distinct kelainan group names for a project.
|
||||
func GetAbnormalGroups(mcuID int) ([]string, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT DISTINCT Mcu_KelainanGroupSummaryName
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND Mcu_KelainanGroupSummaryName IS NOT NULL
|
||||
AND Mcu_KelainanGroupSummaryName != ''
|
||||
ORDER BY Mcu_KelainanGroupSummaryName
|
||||
`, mcuID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []string
|
||||
for rows.Next() {
|
||||
var g string
|
||||
if err := rows.Scan(&g); err != nil {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
// GetAgeChartData returns abnormal patient counts by age bucket.
|
||||
func GetAgeChartData(mcuID int, group string) (AgeChartData, error) {
|
||||
buckets := []string{"<30", "30-39", "40-49", "50+"}
|
||||
counts := make(map[string]int)
|
||||
|
||||
var query string
|
||||
var args []any
|
||||
if group == "" {
|
||||
query = `
|
||||
SELECT
|
||||
CASE
|
||||
WHEN AgePatient < 30 THEN '<30'
|
||||
WHEN AgePatient < 40 THEN '30-39'
|
||||
WHEN AgePatient < 50 THEN '40-49'
|
||||
ELSE '50+'
|
||||
END AS age_group,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND AgePatient IS NOT NULL
|
||||
GROUP BY age_group`
|
||||
args = []any{mcuID}
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
CASE
|
||||
WHEN AgePatient < 30 THEN '<30'
|
||||
WHEN AgePatient < 40 THEN '30-39'
|
||||
WHEN AgePatient < 50 THEN '40-49'
|
||||
ELSE '50+'
|
||||
END AS age_group,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND Mcu_KelainanGroupSummaryName = ?
|
||||
AND AgePatient IS NOT NULL
|
||||
GROUP BY age_group`
|
||||
args = []any{mcuID, group}
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return AgeChartData{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var label string
|
||||
var cnt int
|
||||
if err := rows.Scan(&label, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
counts[label] = cnt
|
||||
}
|
||||
|
||||
data := AgeChartData{Labels: buckets, Abnormal: make([]int, len(buckets))}
|
||||
for i, b := range buckets {
|
||||
data.Abnormal[i] = counts[b]
|
||||
}
|
||||
return data, rows.Err()
|
||||
}
|
||||
|
||||
// GetGenderChartData returns abnormal patient counts by gender.
|
||||
func GetGenderChartData(mcuID int, group string) (GenderChartData, error) {
|
||||
labels := []string{"Male", "Female"}
|
||||
counts := make(map[string]int)
|
||||
|
||||
var query string
|
||||
var args []any
|
||||
if group == "" {
|
||||
query = `
|
||||
SELECT
|
||||
CASE WHEN LOWER(M_PatientGender) = 'male' THEN 'Male' ELSE 'Female' END AS gender,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND M_PatientGender IS NOT NULL
|
||||
GROUP BY gender`
|
||||
args = []any{mcuID}
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
CASE WHEN LOWER(M_PatientGender) = 'male' THEN 'Male' ELSE 'Female' END AS gender,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND Mcu_KelainanGroupSummaryName = ?
|
||||
AND M_PatientGender IS NOT NULL
|
||||
GROUP BY gender`
|
||||
args = []any{mcuID, group}
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return GenderChartData{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var label string
|
||||
var cnt int
|
||||
if err := rows.Scan(&label, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
counts[label] = cnt
|
||||
}
|
||||
|
||||
data := GenderChartData{Labels: labels, Abnormal: make([]int, len(labels))}
|
||||
for i, l := range labels {
|
||||
data.Abnormal[i] = counts[l]
|
||||
}
|
||||
return data, rows.Err()
|
||||
}
|
||||
|
||||
// GetDeptChartData returns top-10 departments by abnormal patient count.
|
||||
func GetDeptChartData(mcuID int, group string) (DeptChartData, error) {
|
||||
var query string
|
||||
var args []any
|
||||
if group == "" {
|
||||
query = `
|
||||
SELECT
|
||||
COALESCE(
|
||||
NULLIF(TRIM(M_PatientDepartement), ''),
|
||||
NULLIF(TRIM(M_PatientDivisi), ''),
|
||||
NULLIF(TRIM(M_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS dept,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
GROUP BY dept
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10`
|
||||
args = []any{mcuID}
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
COALESCE(
|
||||
NULLIF(TRIM(M_PatientDepartement), ''),
|
||||
NULLIF(TRIM(M_PatientDivisi), ''),
|
||||
NULLIF(TRIM(M_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS dept,
|
||||
COUNT(DISTINCT M_PatientID) AS cnt
|
||||
FROM kelainan_details
|
||||
WHERE Mgm_McuID = ?
|
||||
AND Mcu_KelainanGroupSummaryName = ?
|
||||
GROUP BY dept
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10`
|
||||
args = []any{mcuID, group}
|
||||
}
|
||||
|
||||
rows, err := db.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
return DeptChartData{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var data DeptChartData
|
||||
for rows.Next() {
|
||||
var label string
|
||||
var cnt int
|
||||
if err := rows.Scan(&label, &cnt); err != nil {
|
||||
continue
|
||||
}
|
||||
data.Labels = append(data.Labels, label)
|
||||
data.Abnormal = append(data.Abnormal, cnt)
|
||||
}
|
||||
return data, rows.Err()
|
||||
}
|
||||
7
cpone-dashboard/menu/abnormal/route.go
Normal file
7
cpone-dashboard/menu/abnormal/route.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package abnormal
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/", Index)
|
||||
}
|
||||
128
cpone-dashboard/menu/arrival/handler.go
Normal file
128
cpone-dashboard/menu/arrival/handler.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package arrival
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"cpone-dashboard/menu/projects"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var tmpl *template.Template
|
||||
var basePath string
|
||||
|
||||
func SetTemplates(t *template.Template) { tmpl = t }
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type pageData struct {
|
||||
Username string
|
||||
CurrentProject projects.ProjectItem
|
||||
Date string
|
||||
AvailableDates []string
|
||||
Search string
|
||||
Department string
|
||||
Rows []ArrivalRow
|
||||
FilteredRows []ArrivalRow
|
||||
Summary ArrivalSummary
|
||||
Departments []DepartmentStat
|
||||
DepartmentOptions []string
|
||||
OverviewJSON template.JS
|
||||
DepartmentJSON template.JS
|
||||
}
|
||||
|
||||
func toJS(v interface{}) template.JS {
|
||||
b, _ := json.Marshal(v)
|
||||
return template.JS(b)
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
mcuID := auth.SelectedProjectID(r)
|
||||
if mcuID == 0 {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
project, ok, err := projects.GetUserProject(username, mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
dates, err := GetArrivalDates(mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
selectedDate := activeDateOrLatest(dates, r.URL.Query().Get("date"), project.StartDate)
|
||||
rows, err := GetArrivalRows(mcuID, selectedDate)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
summary, deptStats := BuildArrivalStats(rows)
|
||||
filteredRows := FilterArrivalRows(rows, r.URL.Query().Get("search"), r.URL.Query().Get("dept"))
|
||||
deptOptions := UniqueDepartments(rows)
|
||||
|
||||
stationMap, err := GetStationProgress(mcuID, selectedDate)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for i := range filteredRows {
|
||||
filteredRows[i].Stations = stationMap[filteredRows[i].PreregisterID]
|
||||
}
|
||||
|
||||
// Chart 1: double donut — inner: checked-in vs pending, outer: total per posisi/dept
|
||||
outerDepts := []map[string]any{}
|
||||
for _, d := range deptStats {
|
||||
if d.Total > 0 {
|
||||
outerDepts = append(outerDepts, map[string]any{"name": d.Name, "value": d.Total})
|
||||
}
|
||||
}
|
||||
overview := map[string]any{
|
||||
"checkedIn": summary.CheckedIn,
|
||||
"pending": summary.Pending,
|
||||
"depts": outerDepts,
|
||||
}
|
||||
|
||||
// Chart 2: per-station patient count bar chart
|
||||
stationStats := BuildStationChart(stationMap)
|
||||
stationLabels := make([]string, len(stationStats))
|
||||
stationCounts := make([]int, len(stationStats))
|
||||
for i, s := range stationStats {
|
||||
stationLabels[i] = s.Name
|
||||
stationCounts[i] = s.Count
|
||||
}
|
||||
stationChart := map[string]any{
|
||||
"labels": stationLabels,
|
||||
"counts": stationCounts,
|
||||
}
|
||||
|
||||
t := tmpl
|
||||
if t == nil {
|
||||
http.Error(w, "template not ready", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := pageData{
|
||||
Username: username,
|
||||
CurrentProject: project,
|
||||
Date: selectedDate,
|
||||
AvailableDates: dates,
|
||||
Search: r.URL.Query().Get("search"),
|
||||
Department: r.URL.Query().Get("dept"),
|
||||
Rows: rows,
|
||||
FilteredRows: filteredRows,
|
||||
Summary: summary,
|
||||
Departments: deptStats,
|
||||
DepartmentOptions: deptOptions,
|
||||
OverviewJSON: toJS(overview),
|
||||
DepartmentJSON: toJS(stationChart),
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
307
cpone-dashboard/menu/arrival/query.go
Normal file
307
cpone-dashboard/menu/arrival/query.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package arrival
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StationBadge struct {
|
||||
Name string
|
||||
Tone string // "success" | "warning" | "danger" | "neutral"
|
||||
}
|
||||
|
||||
type StationStat struct {
|
||||
Name string
|
||||
Count int
|
||||
}
|
||||
|
||||
type ArrivalRow struct {
|
||||
PreregisterID int
|
||||
NIP string
|
||||
Name string
|
||||
Department string
|
||||
InTime string
|
||||
Status string
|
||||
StatusTone string
|
||||
Stations []StationBadge
|
||||
}
|
||||
|
||||
type DepartmentStat struct {
|
||||
Name string
|
||||
CheckedIn int
|
||||
Pending int
|
||||
Total int
|
||||
}
|
||||
|
||||
type ArrivalSummary struct {
|
||||
CheckedIn int
|
||||
Pending int
|
||||
Total int
|
||||
}
|
||||
|
||||
func GetArrivalDates(mcuID int) ([]string, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT DATE_FORMAT(Mcu_PatientScheduleDate, '%Y-%m-%d') AS schedule_date
|
||||
FROM mcu_patient_schedule
|
||||
WHERE Mcu_PatientSchedulePreregisterID IN (
|
||||
SELECT Mcu_PatientPreregisterID
|
||||
FROM mcu_patient
|
||||
WHERE Mcu_PatientMcuID = ?
|
||||
AND Mcu_PatientIsActive = 'Y'
|
||||
)
|
||||
AND Mcu_PatientScheduleIsActive = 'Y'
|
||||
GROUP BY Mcu_PatientScheduleDate
|
||||
ORDER BY Mcu_PatientScheduleDate DESC
|
||||
`, mcuID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var dates []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if rows.Scan(&d) == nil && d != "" {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
}
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
func GetArrivalRows(mcuID int, date string) ([]ArrivalRow, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
mp.Mcu_PatientPreregisterID,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '') AS nip,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '') AS patient_name,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS department_name,
|
||||
COALESCE(DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i'), '') AS in_time,
|
||||
CASE
|
||||
WHEN mc.Mcu_CheckinoutOutTime IS NOT NULL THEN 'Performed'
|
||||
WHEN mc.Mcu_CheckinoutInTime IS NOT NULL THEN 'In Progress'
|
||||
ELSE 'Not Check-in Yet'
|
||||
END AS status_text,
|
||||
CASE
|
||||
WHEN mc.Mcu_CheckinoutOutTime IS NOT NULL THEN 'success'
|
||||
WHEN mc.Mcu_CheckinoutInTime IS NOT NULL THEN 'warning'
|
||||
ELSE 'neutral'
|
||||
END AS status_tone
|
||||
FROM mcu_patient_schedule s
|
||||
JOIN mcu_patient mp
|
||||
ON mp.Mcu_PatientPreregisterID = s.Mcu_PatientSchedulePreregisterID
|
||||
AND mp.Mcu_PatientMcuID = ?
|
||||
AND mp.Mcu_PatientIsActive = 'Y'
|
||||
LEFT JOIN mcu_checkinout mc
|
||||
ON mc.Mcu_CheckinoutPreregisterID = mp.Mcu_PatientPreregisterID
|
||||
AND mc.Mcu_CheckinoutMcuID = ?
|
||||
AND mc.Mcu_CheckinoutDate = s.Mcu_PatientScheduleDate
|
||||
AND mc.Mcu_CheckinoutIsActive = 'Y'
|
||||
WHERE s.Mcu_PatientScheduleIsActive = 'Y'
|
||||
AND s.Mcu_PatientScheduleDate = ?
|
||||
ORDER BY
|
||||
CASE WHEN mc.Mcu_CheckinoutInTime IS NULL THEN 1 ELSE 0 END,
|
||||
mc.Mcu_CheckinoutInTime DESC,
|
||||
mp.Mcu_PatientName ASC
|
||||
`, mcuID, mcuID, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ArrivalRow
|
||||
for rows.Next() {
|
||||
var r ArrivalRow
|
||||
if err := rows.Scan(&r.PreregisterID, &r.NIP, &r.Name, &r.Department, &r.InTime, &r.Status, &r.StatusTone); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(r.NIP) == "" {
|
||||
r.NIP = "-"
|
||||
}
|
||||
if strings.TrimSpace(r.Name) == "" {
|
||||
r.Name = "-"
|
||||
}
|
||||
if strings.TrimSpace(r.Department) == "" {
|
||||
r.Department = "-"
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func GetStationProgress(mcuID int, date string) (map[int][]StationBadge, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
sp.Mcu_StationProgressPreregisterID,
|
||||
sp.Mcu_StationProgressStationName,
|
||||
CASE
|
||||
WHEN sp.Mcu_StationProgressDoneAt IS NOT NULL THEN 'success'
|
||||
WHEN sp.Mcu_StationProgressProcessAt IS NOT NULL
|
||||
OR sp.Mcu_StationProgressReceiveAt IS NOT NULL
|
||||
OR sp.Mcu_StationProgressSamplingAt IS NOT NULL THEN 'warning'
|
||||
ELSE 'neutral'
|
||||
END AS tone
|
||||
FROM mcu_station_progress sp
|
||||
WHERE sp.Mcu_StationProgressMcuID = ?
|
||||
AND sp.Mcu_StationProgressPreregisterID IN (
|
||||
SELECT mp.Mcu_PatientPreregisterID
|
||||
FROM mcu_patient_schedule s
|
||||
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = s.Mcu_PatientSchedulePreregisterID
|
||||
WHERE mp.Mcu_PatientMcuID = ?
|
||||
AND mp.Mcu_PatientIsActive = 'Y'
|
||||
AND s.Mcu_PatientScheduleDate = ?
|
||||
AND s.Mcu_PatientScheduleIsActive = 'Y'
|
||||
)
|
||||
ORDER BY sp.Mcu_StationProgressPreregisterID, sp.Mcu_StationProgressStationName
|
||||
`, mcuID, mcuID, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := map[int][]StationBadge{}
|
||||
for rows.Next() {
|
||||
var preregID int
|
||||
var name, tone string
|
||||
if err := rows.Scan(&preregID, &name, &tone); err != nil {
|
||||
continue
|
||||
}
|
||||
result[preregID] = append(result[preregID], StationBadge{Name: name, Tone: tone})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BuildArrivalStats(rows []ArrivalRow) (ArrivalSummary, []DepartmentStat) {
|
||||
summary := ArrivalSummary{Total: len(rows)}
|
||||
deptMap := map[string]*DepartmentStat{}
|
||||
|
||||
for _, row := range rows {
|
||||
if row.InTime != "" {
|
||||
summary.CheckedIn++
|
||||
}
|
||||
dept := row.Department
|
||||
if dept == "" {
|
||||
dept = "-"
|
||||
}
|
||||
stat, ok := deptMap[dept]
|
||||
if !ok {
|
||||
stat = &DepartmentStat{Name: dept}
|
||||
deptMap[dept] = stat
|
||||
}
|
||||
stat.Total++
|
||||
if row.InTime != "" {
|
||||
stat.CheckedIn++
|
||||
}
|
||||
}
|
||||
|
||||
summary.Pending = summary.Total - summary.CheckedIn
|
||||
if summary.Pending < 0 {
|
||||
summary.Pending = 0
|
||||
}
|
||||
|
||||
stats := make([]DepartmentStat, 0, len(deptMap))
|
||||
for _, stat := range deptMap {
|
||||
stat.Pending = stat.Total - stat.CheckedIn
|
||||
if stat.Pending < 0 {
|
||||
stat.Pending = 0
|
||||
}
|
||||
stats = append(stats, *stat)
|
||||
}
|
||||
sort.Slice(stats, func(i, j int) bool {
|
||||
if stats[i].CheckedIn != stats[j].CheckedIn {
|
||||
return stats[i].CheckedIn > stats[j].CheckedIn
|
||||
}
|
||||
if stats[i].Total != stats[j].Total {
|
||||
return stats[i].Total > stats[j].Total
|
||||
}
|
||||
return stats[i].Name < stats[j].Name
|
||||
})
|
||||
|
||||
return summary, stats
|
||||
}
|
||||
|
||||
func BuildStationChart(stationMap map[int][]StationBadge) []StationStat {
|
||||
countMap := map[string]int{}
|
||||
for _, badges := range stationMap {
|
||||
for _, b := range badges {
|
||||
countMap[b.Name]++
|
||||
}
|
||||
}
|
||||
stats := make([]StationStat, 0, len(countMap))
|
||||
for name, count := range countMap {
|
||||
stats = append(stats, StationStat{Name: name, Count: count})
|
||||
}
|
||||
sort.Slice(stats, func(i, j int) bool {
|
||||
return stats[i].Count > stats[j].Count
|
||||
})
|
||||
return stats
|
||||
}
|
||||
|
||||
func FilterArrivalRows(rows []ArrivalRow, search, dept string) []ArrivalRow {
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
dept = strings.TrimSpace(dept)
|
||||
if search == "" && dept == "" {
|
||||
return rows
|
||||
}
|
||||
|
||||
out := make([]ArrivalRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if dept != "" && dept != "All Departments" && row.Department != dept {
|
||||
continue
|
||||
}
|
||||
if search != "" {
|
||||
hay := strings.ToLower(row.Name + " " + row.NIP + " " + row.Department)
|
||||
if !strings.Contains(hay, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func UniqueDepartments(rows []ArrivalRow) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
for _, row := range rows {
|
||||
name := strings.TrimSpace(row.Department)
|
||||
if name == "" {
|
||||
name = "-"
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func activeDateOrLatest(dates []string, selected string, fallback string) string {
|
||||
selected = strings.TrimSpace(selected)
|
||||
if selected != "" {
|
||||
for _, d := range dates {
|
||||
if d == selected {
|
||||
return selected
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dates) > 0 {
|
||||
return dates[0]
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func mustDayLabel(date string) string {
|
||||
if len(date) >= 10 {
|
||||
return fmt.Sprintf("%s/%s/%s", date[8:10], date[5:7], date[0:4])
|
||||
}
|
||||
return date
|
||||
}
|
||||
7
cpone-dashboard/menu/arrival/route.go
Normal file
7
cpone-dashboard/menu/arrival/route.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package arrival
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/", Index)
|
||||
}
|
||||
69
cpone-dashboard/menu/auth/handler.go
Normal file
69
cpone-dashboard/menu/auth/handler.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
tmplFS *embed.FS
|
||||
authSecret string
|
||||
basePath string
|
||||
)
|
||||
|
||||
func Init(fs *embed.FS, secret string) {
|
||||
tmplFS = fs
|
||||
authSecret = secret
|
||||
}
|
||||
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type loginData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
func ShowLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if getSession(r, authSecret) != "" {
|
||||
http.Redirect(w, r, basePath+"/dashboard", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
render(w, loginData{})
|
||||
}
|
||||
|
||||
func DoLogin(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
|
||||
var hash, salt string
|
||||
err := db.DB.QueryRow(
|
||||
`SELECT User_Password, COALESCE(User_Salt, '')
|
||||
FROM dashboard_user
|
||||
WHERE User_Username = ? AND User_IsActive = 'Y'`,
|
||||
username,
|
||||
).Scan(&hash, &salt)
|
||||
if err != nil || !checkPassword(password, salt, hash) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
render(w, loginData{Error: "Username atau password salah."})
|
||||
return
|
||||
}
|
||||
|
||||
SetSession(w, username, authSecret)
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func DoLogout(w http.ResponseWriter, r *http.Request) {
|
||||
ClearSession(w)
|
||||
http.Redirect(w, r, basePath+"/mcu-login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func checkPassword(password, salt, storedHash string) bool {
|
||||
return hashPassword(password, salt) == storedHash
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, data loginData) {
|
||||
b := func(path string) string { return basePath + path }
|
||||
t := template.Must(template.New("").Funcs(template.FuncMap{"b": b}).ParseFS(tmplFS, "templates/login/index.html"))
|
||||
t.ExecuteTemplate(w, "login", data)
|
||||
}
|
||||
16
cpone-dashboard/menu/auth/middleware.go
Normal file
16
cpone-dashboard/menu/auth/middleware.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package auth
|
||||
|
||||
import "net/http"
|
||||
|
||||
func Require(secret string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username := getSession(r, secret)
|
||||
if username == "" {
|
||||
http.Redirect(w, r, basePath+"/mcu-login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, setContext(r, username))
|
||||
})
|
||||
}
|
||||
}
|
||||
79
cpone-dashboard/menu/auth/password.go
Normal file
79
cpone-dashboard/menu/auth/password.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type passwordData struct {
|
||||
Username string
|
||||
Error string
|
||||
Success string
|
||||
}
|
||||
|
||||
func ShowPassword(w http.ResponseWriter, r *http.Request) {
|
||||
renderPassword(w, passwordData{Username: Username(r)})
|
||||
}
|
||||
|
||||
func DoChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
username := Username(r)
|
||||
current := r.FormValue("current_password")
|
||||
newPass := r.FormValue("new_password")
|
||||
confirm := r.FormValue("confirm_password")
|
||||
|
||||
fail := func(msg string) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
renderPassword(w, passwordData{Username: username, Error: msg})
|
||||
}
|
||||
|
||||
if newPass != confirm {
|
||||
fail("Password baru dan konfirmasi tidak cocok.")
|
||||
return
|
||||
}
|
||||
if len(newPass) < 6 {
|
||||
fail("Password baru minimal 6 karakter.")
|
||||
return
|
||||
}
|
||||
|
||||
// Verifikasi password lama
|
||||
var storedHash, salt string
|
||||
err := db.DB.QueryRow(
|
||||
`SELECT User_Password, COALESCE(User_Salt, '')
|
||||
FROM dashboard_user WHERE User_Username = ? AND User_IsActive = 'Y'`,
|
||||
username,
|
||||
).Scan(&storedHash, &salt)
|
||||
if err != nil || !checkPassword(current, salt, storedHash) {
|
||||
fail("Password saat ini tidak sesuai.")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate salt + hash baru
|
||||
newSalt := newUUID()
|
||||
newHash := hashPassword(newPass, newSalt)
|
||||
|
||||
_, err = db.DB.Exec(
|
||||
`UPDATE dashboard_user
|
||||
SET User_Password = ?, User_Salt = ?, User_UpdatedAt = NOW()
|
||||
WHERE User_Username = ?`,
|
||||
newHash, newSalt, username,
|
||||
)
|
||||
if err != nil {
|
||||
fail("Gagal menyimpan password baru, coba lagi.")
|
||||
return
|
||||
}
|
||||
|
||||
renderPassword(w, passwordData{Username: username, Success: "Password berhasil diubah."})
|
||||
}
|
||||
|
||||
func renderPassword(w http.ResponseWriter, data passwordData) {
|
||||
t := template.Must(template.ParseFS(tmplFS, "templates/auth/password.html"))
|
||||
t.ExecuteTemplate(w, "password", data)
|
||||
}
|
||||
|
||||
func hashPassword(password, salt string) string {
|
||||
h := sha256.Sum256([]byte(salt + ":" + password))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
14
cpone-dashboard/menu/auth/route.go
Normal file
14
cpone-dashboard/menu/auth/route.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package auth
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/mcu-login", ShowLogin)
|
||||
r.Post("/mcu-login", DoLogin)
|
||||
r.Get("/logout", DoLogout)
|
||||
}
|
||||
|
||||
func ProtectedRoutes(r chi.Router) {
|
||||
r.Get("/password", ShowPassword)
|
||||
r.Post("/password", DoChangePassword)
|
||||
}
|
||||
124
cpone-dashboard/menu/auth/session.go
Normal file
124
cpone-dashboard/menu/auth/session.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const cookieName = "cpone_session"
|
||||
const projectCookieName = "cpone_project_mcu_id"
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const userCtxKey ctxKey = "username"
|
||||
|
||||
// Username returns the logged-in username from the request context.
|
||||
func Username(r *http.Request) string {
|
||||
v, _ := r.Context().Value(userCtxKey).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func setContext(r *http.Request, username string) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), userCtxKey, username))
|
||||
}
|
||||
|
||||
func SetSession(w http.ResponseWriter, username, secret string) {
|
||||
val := encode(username) + "." + sign(username, secret)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: val,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
func ClearSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func SetSelectedProject(w http.ResponseWriter, mcuID int) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: projectCookieName,
|
||||
Value: strconv.Itoa(mcuID),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(7 * 24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
func ClearSelectedProject(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: projectCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func SelectedProjectID(r *http.Request) int {
|
||||
c, err := r.Cookie(projectCookieName)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimSpace(c.Value))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func getSession(r *http.Request, secret string) string {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(c.Value, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
username, err := decode(parts[0])
|
||||
if err != nil || username == "" {
|
||||
return ""
|
||||
}
|
||||
if !hmac.Equal([]byte(sign(username, secret)), []byte(parts[1])) {
|
||||
return ""
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
func newUUID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func sign(username, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(username))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func encode(s string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
func decode(s string) (string, error) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
return string(b), err
|
||||
}
|
||||
287
cpone-dashboard/menu/dashboard/handler.go
Normal file
287
cpone-dashboard/menu/dashboard/handler.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"cpone-dashboard/menu/projects"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var templateFS *embed.FS
|
||||
var funcMap template.FuncMap
|
||||
|
||||
type StationsPartial struct {
|
||||
Rows []StationRow
|
||||
IsLive bool
|
||||
}
|
||||
|
||||
type ArrivalsPartial struct {
|
||||
Rows []ArrivalRow
|
||||
IsLive bool
|
||||
}
|
||||
|
||||
type PatientsPartial struct {
|
||||
Patients []PatientDetail
|
||||
IsRange bool
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
Username string
|
||||
McuID int
|
||||
Project ProjectInfo
|
||||
CurrentProject projects.ProjectItem
|
||||
AvailableDates []string
|
||||
Mode string
|
||||
DateFrom string
|
||||
DateTo string
|
||||
IsRange bool
|
||||
IsLive bool
|
||||
KPI KPIData
|
||||
TAT TATData
|
||||
Stations []StationRow
|
||||
Arrivals []ArrivalRow
|
||||
TATChart template.JS
|
||||
TrendChart template.JS
|
||||
}
|
||||
|
||||
var basePath string
|
||||
|
||||
func SetTemplateFS(fs *embed.FS) { templateFS = fs }
|
||||
func SetTemplateFuncs(fm template.FuncMap) { funcMap = fm }
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
func parse(files ...string) *template.Template {
|
||||
return template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, files...))
|
||||
}
|
||||
|
||||
func defaultDailyDate(project ProjectInfo) string {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if project.StartDate == "" {
|
||||
return today
|
||||
}
|
||||
if project.EndDate != "" && today > project.EndDate {
|
||||
return project.StartDate
|
||||
}
|
||||
if today < project.StartDate {
|
||||
return project.StartDate
|
||||
}
|
||||
return today
|
||||
}
|
||||
|
||||
func containsDate(dates []string, target string) bool {
|
||||
for _, d := range dates {
|
||||
if d == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func activeDateRange(r *http.Request, project ProjectInfo, availableDates []string) (mode, from, to string) {
|
||||
mode = "daily"
|
||||
reqDate := r.URL.Query().Get("date")
|
||||
|
||||
switch {
|
||||
case reqDate != "" && containsDate(availableDates, reqDate):
|
||||
from = reqDate
|
||||
case containsDate(availableDates, time.Now().Format("2006-01-02")):
|
||||
from = time.Now().Format("2006-01-02")
|
||||
case len(availableDates) > 0:
|
||||
from = availableDates[0]
|
||||
default:
|
||||
from = defaultDailyDate(project)
|
||||
}
|
||||
to = from
|
||||
return
|
||||
}
|
||||
|
||||
func activeMcuID(r *http.Request) int {
|
||||
if id, _ := strconv.Atoi(r.URL.Query().Get("mcu_id")); id > 0 {
|
||||
return id
|
||||
}
|
||||
return auth.SelectedProjectID(r)
|
||||
}
|
||||
|
||||
func toJS(v interface{}) template.JS {
|
||||
b, _ := json.Marshal(v)
|
||||
return template.JS(b)
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
mcuID := activeMcuID(r)
|
||||
username := auth.Username(r)
|
||||
|
||||
if mcuID == 0 {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := projects.HasAccess(username, mcuID)
|
||||
if err != nil {
|
||||
log.Printf("[dashboard] HasAccess error: %v", err)
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
project, err := GetProject(mcuID)
|
||||
if err != nil {
|
||||
log.Printf("[dashboard] GetActiveProject error: %v", err)
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
currentProject := projects.ProjectItem{}
|
||||
if item, ok, err := projects.GetUserProject(username, mcuID); err != nil {
|
||||
log.Printf("[dashboard] GetUserProject error: %v", err)
|
||||
} else if ok {
|
||||
currentProject = item
|
||||
auth.SetSelectedProject(w, mcuID)
|
||||
}
|
||||
availableDates, err := GetCheckinDates(project.McuID)
|
||||
if err != nil {
|
||||
log.Printf("[dashboard] GetCheckinDates error: %v", err)
|
||||
}
|
||||
mode, dateFrom, dateTo := activeDateRange(r, project, availableDates)
|
||||
isRange := dateFrom != dateTo
|
||||
|
||||
kpi, err := GetKPI(project.McuID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
log.Printf("[dashboard] GetKPI error: %v", err)
|
||||
}
|
||||
tat, err := GetTAT(project.McuID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
log.Printf("[dashboard] GetTAT error: %v", err)
|
||||
}
|
||||
stations, err := GetStations(project.McuID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
log.Printf("[dashboard] GetStations error: %v", err)
|
||||
}
|
||||
arrivals, err := GetArrivals(project.McuID, dateFrom, dateTo, 8)
|
||||
if err != nil {
|
||||
log.Printf("[dashboard] GetArrivals error: %v", err)
|
||||
}
|
||||
hourlyTAT, err := GetPeriodTAT(project.McuID, dateFrom, dateTo, isRange)
|
||||
if err != nil {
|
||||
log.Printf("[dashboard] GetPeriodTAT error: %v", err)
|
||||
}
|
||||
trend, err := GetPeriodTrend(project.McuID, dateFrom, dateTo, isRange)
|
||||
if err != nil {
|
||||
log.Printf("[dashboard] GetPeriodTrend error: %v", err)
|
||||
}
|
||||
|
||||
// Build chart JSON payloads
|
||||
tatLabels, tatValues := []string{}, []float64{}
|
||||
for _, p := range hourlyTAT {
|
||||
tatLabels = append(tatLabels, p.Label)
|
||||
tatValues = append(tatValues, p.Value)
|
||||
}
|
||||
trendLabels, trendCI, trendCO := []string{}, []int{}, []int{}
|
||||
for _, p := range trend {
|
||||
trendLabels = append(trendLabels, p.Label)
|
||||
trendCI = append(trendCI, p.CheckedIn)
|
||||
trendCO = append(trendCO, p.CheckedOut)
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
data := PageData{
|
||||
Username: username,
|
||||
McuID: project.McuID,
|
||||
Project: project,
|
||||
CurrentProject: currentProject,
|
||||
AvailableDates: availableDates,
|
||||
Mode: mode,
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
IsRange: isRange,
|
||||
IsLive: mode == "daily" && dateFrom == today,
|
||||
KPI: kpi,
|
||||
TAT: tat,
|
||||
Stations: stations,
|
||||
Arrivals: arrivals,
|
||||
TATChart: toJS(map[string]interface{}{
|
||||
"labels": tatLabels,
|
||||
"values": tatValues,
|
||||
}),
|
||||
TrendChart: toJS(map[string]interface{}{
|
||||
"labels": trendLabels,
|
||||
"checkedIn": trendCI,
|
||||
"checkedOut": trendCO,
|
||||
}),
|
||||
}
|
||||
|
||||
t := parse("templates/layout/base.html", "templates/dashboard/index.html")
|
||||
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||
log.Printf("[dashboard] template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Patients(w http.ResponseWriter, r *http.Request) {
|
||||
project, err := GetProject(activeMcuID(r))
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
availableDates, _ := GetCheckinDates(project.McuID)
|
||||
mode, dateFrom, dateTo := activeDateRange(r, project, availableDates)
|
||||
patients, err := GetAllPatients(project.McuID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
log.Printf("[dashboard] GetAllPatients error: %v", err)
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := PatientsPartial{
|
||||
Patients: patients,
|
||||
IsRange: mode != "daily" || dateFrom != dateTo,
|
||||
}
|
||||
t := parse("templates/dashboard/partials/patients.html")
|
||||
if err := t.ExecuteTemplate(w, "patients", data); err != nil {
|
||||
log.Printf("[dashboard] patients template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func KPI(w http.ResponseWriter, r *http.Request) {
|
||||
project, _ := GetProject(activeMcuID(r))
|
||||
availableDates, _ := GetCheckinDates(project.McuID)
|
||||
_, from, to := activeDateRange(r, project, availableDates)
|
||||
data, err := GetKPI(project.McuID, from, to)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
t := parse("templates/dashboard/partials/kpi.html")
|
||||
t.ExecuteTemplate(w, "kpi", data)
|
||||
}
|
||||
|
||||
func Stations(w http.ResponseWriter, r *http.Request) {
|
||||
project, _ := GetProject(activeMcuID(r))
|
||||
availableDates, _ := GetCheckinDates(project.McuID)
|
||||
_, from, to := activeDateRange(r, project, availableDates)
|
||||
rows, err := GetStations(project.McuID, from, to)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
t := parse("templates/dashboard/partials/stations.html")
|
||||
t.ExecuteTemplate(w, "stations", StationsPartial{Rows: rows, IsLive: from == time.Now().Format("2006-01-02")})
|
||||
}
|
||||
|
||||
func Arrivals(w http.ResponseWriter, r *http.Request) {
|
||||
project, _ := GetProject(activeMcuID(r))
|
||||
availableDates, _ := GetCheckinDates(project.McuID)
|
||||
_, from, to := activeDateRange(r, project, availableDates)
|
||||
rows, err := GetArrivals(project.McuID, from, to, 8)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
t := parse("templates/dashboard/partials/arrivals.html")
|
||||
t.ExecuteTemplate(w, "arrivals", rows)
|
||||
}
|
||||
505
cpone-dashboard/menu/dashboard/query.go
Normal file
505
cpone-dashboard/menu/dashboard/query.go
Normal file
@@ -0,0 +1,505 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const checkinOutTimestampExpr = "TIMESTAMP(Mcu_CheckinoutDate, Mcu_CheckinoutOutTime)"
|
||||
|
||||
type ProjectInfo struct {
|
||||
McuID int
|
||||
CorporateName string
|
||||
Label string
|
||||
Number string
|
||||
StartDate string
|
||||
EndDate string
|
||||
TotalStaff int
|
||||
}
|
||||
|
||||
type KPIData struct {
|
||||
InvitedStaff int // dari mcu_participant_daily, date-filtered — untuk widget kanan atas
|
||||
TotalStaff int // dari mcu_checkinout, seluruh project — untuk KPI card
|
||||
CheckedIn int // dari mcu_checkinout, date-filtered
|
||||
CheckedOut int // dari mcu_checkinout, date-filtered
|
||||
}
|
||||
|
||||
type StationRow struct {
|
||||
Station string
|
||||
Processed int
|
||||
Pending int
|
||||
Total int
|
||||
Pct float64
|
||||
}
|
||||
|
||||
type ArrivalRow struct {
|
||||
Name string
|
||||
InTime string
|
||||
Date string
|
||||
Station string
|
||||
}
|
||||
|
||||
type TATData struct {
|
||||
AvgMinutes int
|
||||
Fastest int
|
||||
Median int
|
||||
CheckedOut int
|
||||
}
|
||||
|
||||
type ChartPoint struct {
|
||||
Label string
|
||||
Value float64
|
||||
}
|
||||
|
||||
type TrendPoint struct {
|
||||
Label string
|
||||
CheckedIn int
|
||||
CheckedOut int
|
||||
}
|
||||
|
||||
// GetProject returns project by mcuID. If mcuID == 0, returns the active project.
|
||||
func GetProject(mcuID int) (ProjectInfo, error) {
|
||||
var p ProjectInfo
|
||||
var err error
|
||||
const cols = `SELECT Mcu_ProjectMcuID, Mcu_ProjectCorporateName, Mcu_ProjectLabel,
|
||||
Mcu_ProjectNumber,
|
||||
DATE_FORMAT(Mcu_ProjectStartDate, '%Y-%m-%d'),
|
||||
DATE_FORMAT(Mcu_ProjectEndDate, '%Y-%m-%d'),
|
||||
Mcu_ProjectTotalParticipant
|
||||
FROM mcu_project`
|
||||
if mcuID > 0 {
|
||||
err = db.DB.QueryRow(cols+` WHERE Mcu_ProjectMcuID = ?`, mcuID).
|
||||
Scan(&p.McuID, &p.CorporateName, &p.Label, &p.Number, &p.StartDate, &p.EndDate, &p.TotalStaff)
|
||||
} else {
|
||||
err = db.DB.QueryRow(cols+` WHERE Mcu_ProjectIsActive = 'Y' ORDER BY Mcu_ProjectStartDate DESC LIMIT 1`).
|
||||
Scan(&p.McuID, &p.CorporateName, &p.Label, &p.Number, &p.StartDate, &p.EndDate, &p.TotalStaff)
|
||||
}
|
||||
if err == sql.ErrNoRows {
|
||||
return p, nil
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func GetKPI(mcuID int, dateFrom, dateTo string) (KPIData, error) {
|
||||
var d KPIData
|
||||
|
||||
// Invited staff: dari mcu_participant_daily, date-filtered — widget kanan atas
|
||||
db.DB.QueryRow(`
|
||||
SELECT COALESCE(SUM(Mcu_ParticipantDailyTotal), 0)
|
||||
FROM mcu_participant_daily
|
||||
WHERE Mcu_ParticipantDailyMcuID = ?
|
||||
AND Mcu_ParticipantDailyDate BETWEEN ? AND ?
|
||||
AND Mcu_ParticipantDailyIsActive = 'Y'
|
||||
`, mcuID, dateFrom, dateTo).Scan(&d.InvitedStaff)
|
||||
|
||||
// Total staff: semua yang datang (checkin) pada tanggal filter
|
||||
db.DB.QueryRow(`
|
||||
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout
|
||||
WHERE Mcu_CheckinoutMcuID = ?
|
||||
AND Mcu_CheckinoutDate BETWEEN ? AND ?
|
||||
AND Mcu_CheckinoutIsActive = 'Y'
|
||||
`, mcuID, dateFrom, dateTo).Scan(&d.TotalStaff)
|
||||
|
||||
// Checked-in: masih di dalam (belum checkout)
|
||||
db.DB.QueryRow(`
|
||||
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout
|
||||
WHERE Mcu_CheckinoutMcuID = ?
|
||||
AND Mcu_CheckinoutDate BETWEEN ? AND ?
|
||||
AND Mcu_CheckinoutOutTime IS NULL
|
||||
AND Mcu_CheckinoutIsActive = 'Y'
|
||||
`, mcuID, dateFrom, dateTo).Scan(&d.CheckedIn)
|
||||
|
||||
// Checked-out: sudah selesai
|
||||
db.DB.QueryRow(`
|
||||
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout
|
||||
WHERE Mcu_CheckinoutMcuID = ?
|
||||
AND Mcu_CheckinoutDate BETWEEN ? AND ?
|
||||
AND Mcu_CheckinoutOutTime IS NOT NULL
|
||||
AND Mcu_CheckinoutIsActive = 'Y'
|
||||
`, mcuID, dateFrom, dateTo).Scan(&d.CheckedOut)
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func GetCheckinDates(mcuID int) ([]string, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT DATE_FORMAT(Mcu_CheckinoutDate, '%Y-%m-%d') AS checkin_date
|
||||
FROM mcu_checkinout
|
||||
WHERE Mcu_CheckinoutMcuID = ?
|
||||
AND Mcu_CheckinoutIsActive = 'Y'
|
||||
GROUP BY Mcu_CheckinoutDate
|
||||
ORDER BY Mcu_CheckinoutDate DESC
|
||||
`, mcuID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var dates []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if rows.Scan(&d) == nil && d != "" {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
}
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
func GetTAT(mcuID int, dateFrom, dateTo string) (TATData, error) {
|
||||
var d TATData
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT TIMESTAMPDIFF(MINUTE,
|
||||
TIMESTAMP(Mcu_CheckinoutDate, Mcu_CheckinoutInTime),
|
||||
`+checkinOutTimestampExpr+`
|
||||
) AS tat
|
||||
FROM mcu_checkinout
|
||||
WHERE Mcu_CheckinoutMcuID = ?
|
||||
AND Mcu_CheckinoutDate BETWEEN ? AND ?
|
||||
AND Mcu_CheckinoutOutTime IS NOT NULL
|
||||
AND Mcu_CheckinoutInTime IS NOT NULL
|
||||
AND Mcu_CheckinoutIsActive = 'Y'
|
||||
ORDER BY tat
|
||||
`, mcuID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var vals []int
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if rows.Scan(&v) == nil && v > 0 {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return d, nil
|
||||
}
|
||||
sum := 0
|
||||
for _, v := range vals {
|
||||
sum += v
|
||||
}
|
||||
d.CheckedOut = len(vals)
|
||||
d.AvgMinutes = sum / len(vals)
|
||||
d.Fastest = vals[0]
|
||||
d.Median = vals[len(vals)/2]
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func GetStations(mcuID int, dateFrom, dateTo string) ([]StationRow, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT rs.station_name,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN spd.first_done_date IS NULL OR spd.first_done_date >= mc.Mcu_CheckinoutDate
|
||||
THEN mc.Mcu_CheckinoutPreregisterID
|
||||
END) AS total_required,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN spd.first_done_date = mc.Mcu_CheckinoutDate
|
||||
THEN mc.Mcu_CheckinoutPreregisterID
|
||||
ELSE NULL
|
||||
END) AS processed
|
||||
FROM mcu_checkinout mc
|
||||
JOIN mcu_patient_required_station rs
|
||||
ON rs.preregister_id = mc.Mcu_CheckinoutPreregisterID
|
||||
AND rs.mcu_id = mc.Mcu_CheckinoutMcuID
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
sp.Mcu_StationProgressPreregisterID AS preregister_id,
|
||||
sp.Mcu_StationProgressStationID AS station_id,
|
||||
MIN(CASE
|
||||
WHEN sp.Mcu_StationProgressSource = 'lab'
|
||||
AND sp.Mcu_StationProgressReceiveAt IS NOT NULL
|
||||
THEN DATE(sp.Mcu_StationProgressReceiveAt)
|
||||
WHEN sp.Mcu_StationProgressSource = 'nonlab'
|
||||
AND sp.Mcu_StationProgressDoneAt IS NOT NULL
|
||||
THEN DATE(sp.Mcu_StationProgressDoneAt)
|
||||
ELSE NULL
|
||||
END) AS first_done_date
|
||||
FROM mcu_station_progress sp
|
||||
WHERE sp.Mcu_StationProgressMcuID = ?
|
||||
GROUP BY sp.Mcu_StationProgressPreregisterID, sp.Mcu_StationProgressStationID
|
||||
) spd
|
||||
ON spd.preregister_id = mc.Mcu_CheckinoutPreregisterID
|
||||
AND spd.station_id = rs.sample_station_id
|
||||
WHERE mc.Mcu_CheckinoutMcuID = ?
|
||||
AND mc.Mcu_CheckinoutDate BETWEEN ? AND ?
|
||||
AND mc.Mcu_CheckinoutIsActive = 'Y'
|
||||
GROUP BY rs.station_name
|
||||
HAVING total_required > 0
|
||||
ORDER BY processed DESC, rs.station_name ASC
|
||||
`, mcuID, mcuID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []StationRow
|
||||
for rows.Next() {
|
||||
var r StationRow
|
||||
if err := rows.Scan(&r.Station, &r.Total, &r.Processed); err != nil {
|
||||
continue
|
||||
}
|
||||
r.Pending = r.Total - r.Processed
|
||||
if r.Pending < 0 {
|
||||
r.Pending = 0
|
||||
}
|
||||
if r.Total > 0 {
|
||||
r.Pct = float64(r.Processed) / float64(r.Total) * 100
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func GetArrivals(mcuID int, dateFrom, dateTo string, limit int) ([]ArrivalRow, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT mp.Mcu_PatientName,
|
||||
DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i:%s') AS in_time,
|
||||
DATE_FORMAT(mc.Mcu_CheckinoutDate, '%Y-%m-%d') AS checkin_date,
|
||||
COALESCE(
|
||||
(SELECT Mcu_StationProgressStationName
|
||||
FROM mcu_station_progress
|
||||
WHERE Mcu_StationProgressPreregisterID = mc.Mcu_CheckinoutPreregisterID
|
||||
AND Mcu_StationProgressCheckinDate = mc.Mcu_CheckinoutDate
|
||||
AND Mcu_StationProgressDoneAt IS NOT NULL
|
||||
ORDER BY Mcu_StationProgressDoneAt DESC LIMIT 1),
|
||||
'Check-in'
|
||||
) AS last_station
|
||||
FROM mcu_checkinout mc
|
||||
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = mc.Mcu_CheckinoutPreregisterID
|
||||
WHERE mc.Mcu_CheckinoutMcuID = ?
|
||||
AND mc.Mcu_CheckinoutDate BETWEEN ? AND ?
|
||||
AND mc.Mcu_CheckinoutIsActive = 'Y'
|
||||
ORDER BY mc.Mcu_CheckinoutDate DESC, mc.Mcu_CheckinoutInTime DESC
|
||||
LIMIT ?
|
||||
`, mcuID, dateFrom, dateTo, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ArrivalRow
|
||||
for rows.Next() {
|
||||
var r ArrivalRow
|
||||
if rows.Scan(&r.Name, &r.InTime, &r.Date, &r.Station) == nil {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPeriodTAT — tampilkan agregasi per jam untuk daily maupun range
|
||||
func GetPeriodTAT(mcuID int, dateFrom, dateTo string, isRange bool) ([]ChartPoint, error) {
|
||||
_ = isRange
|
||||
groupExpr := "HOUR(Mcu_CheckinoutInTime)"
|
||||
labelExpr := "CONCAT(LPAD(HOUR(Mcu_CheckinoutInTime), 2, '0'), ':00')"
|
||||
|
||||
q := fmt.Sprintf(`
|
||||
SELECT %s AS label,
|
||||
AVG(TIMESTAMPDIFF(MINUTE,
|
||||
TIMESTAMP(Mcu_CheckinoutDate, Mcu_CheckinoutInTime),
|
||||
%s
|
||||
)) AS avg_tat
|
||||
FROM mcu_checkinout
|
||||
WHERE Mcu_CheckinoutMcuID = ?
|
||||
AND Mcu_CheckinoutDate BETWEEN ? AND ?
|
||||
AND Mcu_CheckinoutOutTime IS NOT NULL
|
||||
AND Mcu_CheckinoutInTime IS NOT NULL
|
||||
AND Mcu_CheckinoutIsActive = 'Y'
|
||||
GROUP BY %s
|
||||
ORDER BY %s
|
||||
`, labelExpr, checkinOutTimestampExpr, groupExpr, groupExpr)
|
||||
|
||||
rows, err := db.DB.Query(q, mcuID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ChartPoint
|
||||
for rows.Next() {
|
||||
var p ChartPoint
|
||||
if rows.Scan(&p.Label, &p.Value) == nil {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPeriodTrend — tampilkan hitungan per jam untuk daily maupun range
|
||||
func GetPeriodTrend(mcuID int, dateFrom, dateTo string, isRange bool) ([]TrendPoint, error) {
|
||||
_ = isRange
|
||||
q := `
|
||||
SELECT hour_no,
|
||||
CONCAT(LPAD(hour_no, 2, '0'), ':00') AS label,
|
||||
SUM(checked_in) AS checked_in,
|
||||
SUM(checked_out) AS checked_out
|
||||
FROM (
|
||||
SELECT HOUR(Mcu_CheckinoutInTime) AS hour_no,
|
||||
COUNT(*) AS checked_in,
|
||||
0 AS checked_out
|
||||
FROM mcu_checkinout
|
||||
WHERE Mcu_CheckinoutMcuID = ?
|
||||
AND Mcu_CheckinoutDate BETWEEN ? AND ?
|
||||
AND Mcu_CheckinoutInTime IS NOT NULL
|
||||
AND Mcu_CheckinoutIsActive = 'Y'
|
||||
GROUP BY HOUR(Mcu_CheckinoutInTime)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT HOUR(Mcu_CheckinoutOutTime) AS hour_no,
|
||||
0 AS checked_in,
|
||||
COUNT(*) AS checked_out
|
||||
FROM mcu_checkinout
|
||||
WHERE Mcu_CheckinoutMcuID = ?
|
||||
AND Mcu_CheckinoutDate BETWEEN ? AND ?
|
||||
AND Mcu_CheckinoutOutTime IS NOT NULL
|
||||
AND Mcu_CheckinoutIsActive = 'Y'
|
||||
GROUP BY HOUR(Mcu_CheckinoutOutTime)
|
||||
) t
|
||||
GROUP BY hour_no
|
||||
ORDER BY hour_no
|
||||
`
|
||||
|
||||
rows, err := db.DB.Query(q, mcuID, dateFrom, dateTo, mcuID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Build cumulative
|
||||
var points []TrendPoint
|
||||
cumCI, cumCO := 0, 0
|
||||
for rows.Next() {
|
||||
var hourNo int
|
||||
var label string
|
||||
var ci, co int
|
||||
if rows.Scan(&hourNo, &label, &ci, &co) == nil {
|
||||
cumCI += ci
|
||||
cumCO += co
|
||||
points = append(points, TrendPoint{
|
||||
Label: label,
|
||||
CheckedIn: cumCI,
|
||||
CheckedOut: cumCO,
|
||||
})
|
||||
}
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
type PatientStationStatus struct {
|
||||
Station string
|
||||
Done bool
|
||||
ProcessAt string
|
||||
DoneAt string
|
||||
}
|
||||
|
||||
type PatientDetail struct {
|
||||
ID int
|
||||
Name string
|
||||
Date string
|
||||
InTime string
|
||||
OutTime string
|
||||
HasOut bool
|
||||
Stations []PatientStationStatus
|
||||
DoneCount int
|
||||
}
|
||||
|
||||
type patientKey struct {
|
||||
ID int
|
||||
Date string
|
||||
}
|
||||
|
||||
func GetAllPatients(mcuID int, dateFrom, dateTo string) ([]PatientDetail, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
mc.Mcu_CheckinoutPreregisterID,
|
||||
mp.Mcu_PatientName,
|
||||
DATE_FORMAT(mc.Mcu_CheckinoutDate, '%Y-%m-%d'),
|
||||
DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i'),
|
||||
COALESCE(DATE_FORMAT(mc.Mcu_CheckinoutOutTime, '%H:%i'), ''),
|
||||
IF(mc.Mcu_CheckinoutOutTime IS NOT NULL, 1, 0),
|
||||
rs.station_name,
|
||||
IF(
|
||||
(sp.Mcu_StationProgressSource = 'lab' AND sp.Mcu_StationProgressReceiveAt IS NOT NULL AND DATE(sp.Mcu_StationProgressReceiveAt) <= mc.Mcu_CheckinoutDate)
|
||||
OR (sp.Mcu_StationProgressSource = 'nonlab' AND sp.Mcu_StationProgressDoneAt IS NOT NULL AND DATE(sp.Mcu_StationProgressDoneAt) <= mc.Mcu_CheckinoutDate),
|
||||
1, 0),
|
||||
COALESCE(DATE_FORMAT(
|
||||
CASE
|
||||
WHEN sp.Mcu_StationProgressSource = 'lab'
|
||||
THEN COALESCE(sp.Mcu_StationProgressSamplingAt, sp.Mcu_StationProgressProcessAt, sp.Mcu_StationProgressReceiveAt)
|
||||
WHEN sp.Mcu_StationProgressSource = 'nonlab'
|
||||
THEN COALESCE(sp.Mcu_StationProgressProcessAt, sp.Mcu_StationProgressDoneAt)
|
||||
ELSE NULL
|
||||
END,
|
||||
'%d/%m/%Y %H:%i'), ''),
|
||||
COALESCE(DATE_FORMAT(
|
||||
IF(sp.Mcu_StationProgressSource = 'lab', sp.Mcu_StationProgressReceiveAt, sp.Mcu_StationProgressDoneAt),
|
||||
'%d/%m/%Y %H:%i'), '')
|
||||
FROM mcu_checkinout mc
|
||||
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = mc.Mcu_CheckinoutPreregisterID
|
||||
JOIN mcu_patient_required_station rs ON
|
||||
rs.preregister_id = mc.Mcu_CheckinoutPreregisterID
|
||||
AND rs.mcu_id = ?
|
||||
LEFT JOIN mcu_station_progress sp ON
|
||||
sp.Mcu_StationProgressPreregisterID = mc.Mcu_CheckinoutPreregisterID
|
||||
AND sp.Mcu_StationProgressStationID = rs.sample_station_id
|
||||
AND sp.Mcu_StationProgressMcuID = ?
|
||||
AND sp.Mcu_StationProgressCheckinDate = (
|
||||
SELECT MAX(sp2.Mcu_StationProgressCheckinDate)
|
||||
FROM mcu_station_progress sp2
|
||||
WHERE sp2.Mcu_StationProgressPreregisterID = mc.Mcu_CheckinoutPreregisterID
|
||||
AND sp2.Mcu_StationProgressStationID = rs.sample_station_id
|
||||
AND sp2.Mcu_StationProgressMcuID = ?
|
||||
AND sp2.Mcu_StationProgressCheckinDate <= mc.Mcu_CheckinoutDate
|
||||
)
|
||||
WHERE mc.Mcu_CheckinoutMcuID = ?
|
||||
AND mc.Mcu_CheckinoutDate BETWEEN ? AND ?
|
||||
AND mc.Mcu_CheckinoutIsActive = 'Y'
|
||||
ORDER BY mc.Mcu_CheckinoutDate DESC, mc.Mcu_CheckinoutInTime DESC, rs.station_name
|
||||
`, mcuID, mcuID, mcuID, mcuID, dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var patients []PatientDetail
|
||||
keyIndex := map[patientKey]int{}
|
||||
|
||||
for rows.Next() {
|
||||
var pid int
|
||||
var name, date, inTime, outTime, stationName, processAt, doneAt string
|
||||
var hasOutInt, doneInt int
|
||||
if err := rows.Scan(&pid, &name, &date, &inTime, &outTime, &hasOutInt, &stationName, &doneInt, &processAt, &doneAt); err != nil {
|
||||
continue
|
||||
}
|
||||
k := patientKey{ID: pid, Date: date}
|
||||
idx, ok := keyIndex[k]
|
||||
if !ok {
|
||||
p := PatientDetail{
|
||||
ID: pid,
|
||||
Name: name,
|
||||
Date: date,
|
||||
InTime: inTime,
|
||||
OutTime: outTime,
|
||||
HasOut: hasOutInt == 1,
|
||||
}
|
||||
keyIndex[k] = len(patients)
|
||||
patients = append(patients, p)
|
||||
idx = len(patients) - 1
|
||||
}
|
||||
done := doneInt == 1
|
||||
patients[idx].Stations = append(patients[idx].Stations, PatientStationStatus{
|
||||
Station: stationName,
|
||||
Done: done,
|
||||
ProcessAt: processAt,
|
||||
DoneAt: doneAt,
|
||||
})
|
||||
if done {
|
||||
patients[idx].DoneCount++
|
||||
}
|
||||
}
|
||||
|
||||
return patients, nil
|
||||
}
|
||||
12
cpone-dashboard/menu/dashboard/route.go
Normal file
12
cpone-dashboard/menu/dashboard/route.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package dashboard
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/", Index)
|
||||
r.Get("/stream", SSEStream) // SSE endpoint
|
||||
r.Get("/kpi", KPI)
|
||||
r.Get("/stations", Stations)
|
||||
r.Get("/arrivals", Arrivals)
|
||||
r.Get("/patients", Patients)
|
||||
}
|
||||
99
cpone-dashboard/menu/dashboard/sse.go
Normal file
99
cpone-dashboard/menu/dashboard/sse.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type pollState struct {
|
||||
kpiHash string
|
||||
stationsHash string
|
||||
arrivalsHash string
|
||||
}
|
||||
|
||||
func formatSSE(event, html string) string {
|
||||
data := strings.ReplaceAll(strings.TrimSpace(html), "\n", " ")
|
||||
return fmt.Sprintf("event: %s\ndata: %s\n\n", event, data)
|
||||
}
|
||||
|
||||
func renderPartial(name string, data interface{}) string {
|
||||
t := parse("templates/dashboard/partials/" + name + ".html")
|
||||
var buf bytes.Buffer
|
||||
t.ExecuteTemplate(&buf, name, data)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func SSEStream(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // penting untuk nginx reverse proxy
|
||||
|
||||
project, _ := GetProject(activeMcuID(r))
|
||||
if project.McuID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
availableDates, _ := GetCheckinDates(project.McuID)
|
||||
mode, dateFrom, dateTo := activeDateRange(r, project, availableDates)
|
||||
isLive := mode == "daily" && dateFrom == time.Now().Format("2006-01-02")
|
||||
var prev pollState
|
||||
|
||||
pushKPI := func(force bool) {
|
||||
kpi, _ := GetKPI(project.McuID, dateFrom, dateTo)
|
||||
key := fmt.Sprintf("%d|%d|%d", kpi.TotalStaff, kpi.CheckedIn, kpi.CheckedOut)
|
||||
if force || key != prev.kpiHash {
|
||||
fmt.Fprint(w, formatSSE("kpi", renderPartial("kpi", kpi)))
|
||||
prev.kpiHash = key
|
||||
}
|
||||
}
|
||||
|
||||
pushStations := func(force bool) {
|
||||
rows, _ := GetStations(project.McuID, dateFrom, dateTo)
|
||||
key := fmt.Sprintf("%v", rows)
|
||||
if force || key != prev.stationsHash {
|
||||
fmt.Fprint(w, formatSSE("stations", renderPartial("stations", StationsPartial{Rows: rows, IsLive: isLive})))
|
||||
prev.stationsHash = key
|
||||
}
|
||||
}
|
||||
|
||||
pushArrivals := func(force bool) {
|
||||
rows, _ := GetArrivals(project.McuID, dateFrom, dateTo, 8)
|
||||
key := fmt.Sprintf("%v", rows)
|
||||
if force || key != prev.arrivalsHash {
|
||||
fmt.Fprint(w, formatSSE("arrivals", renderPartial("arrivals", ArrivalsPartial{Rows: rows, IsLive: isLive})))
|
||||
prev.arrivalsHash = key
|
||||
}
|
||||
}
|
||||
|
||||
// Kirim data langsung saat connect (force=true)
|
||||
pushKPI(true)
|
||||
pushStations(true)
|
||||
pushArrivals(true)
|
||||
flusher.Flush()
|
||||
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
pushKPI(false)
|
||||
pushStations(false)
|
||||
pushArrivals(false)
|
||||
flusher.Flush()
|
||||
case <-r.Context().Done():
|
||||
// Browser disconnect — goroutine ini langsung berhenti
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
74
cpone-dashboard/menu/progress/handler.go
Normal file
74
cpone-dashboard/menu/progress/handler.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"cpone-dashboard/menu/projects"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var tmpl *template.Template
|
||||
var basePath string
|
||||
|
||||
func SetTemplates(t *template.Template) { tmpl = t }
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type pageData struct {
|
||||
Username string
|
||||
CurrentProject projects.ProjectItem
|
||||
Search string
|
||||
Status string
|
||||
Rows []ProgressRow
|
||||
FilteredRows []ProgressRow
|
||||
Summary ProgressSummary
|
||||
ValidatedPct int
|
||||
PublishedPct int
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
mcuID := auth.SelectedProjectID(r)
|
||||
if mcuID == 0 {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
project, ok, err := projects.GetUserProject(username, mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := GetProgressRows(mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
summary := BuildProgressSummary(rows)
|
||||
search := r.URL.Query().Get("search")
|
||||
status := r.URL.Query().Get("status")
|
||||
filteredRows := FilterProgressRows(rows, search, status)
|
||||
|
||||
t := tmpl
|
||||
if t == nil {
|
||||
http.Error(w, "template not ready", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "base", pageData{
|
||||
Username: username,
|
||||
CurrentProject: project,
|
||||
Search: search,
|
||||
Status: status,
|
||||
Rows: rows,
|
||||
FilteredRows: filteredRows,
|
||||
Summary: summary,
|
||||
ValidatedPct: Pct(summary.Validated, summary.Total),
|
||||
PublishedPct: Pct(summary.Published, summary.Total),
|
||||
}); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
120
cpone-dashboard/menu/progress/query.go
Normal file
120
cpone-dashboard/menu/progress/query.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package progress
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ProgressRow struct {
|
||||
PreregisterID int
|
||||
NIP string
|
||||
Name string
|
||||
Posisi string
|
||||
ResumeStatus string
|
||||
Validated string
|
||||
Published string
|
||||
}
|
||||
|
||||
type ProgressSummary struct {
|
||||
Total int
|
||||
Validated int
|
||||
Published int
|
||||
}
|
||||
|
||||
func GetProgressRows(mcuID int) ([]ProgressRow, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
mp.Mcu_PatientPreregisterID,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '-') AS nip,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '-') AS name,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS posisi,
|
||||
COALESCE(rs.Mcu_PatientResumeStatusStatus, '') AS resume_status,
|
||||
COALESCE(rs.Mcu_PatientResumeStatusValidated, 'N') AS validated,
|
||||
COALESCE(rs.Mcu_PatientResumeStatusPublished, 'N') AS published
|
||||
FROM mcu_patient mp
|
||||
LEFT JOIN mcu_patient_resume_status rs
|
||||
ON rs.Mcu_PatientResumeStatusPreregisterID = mp.Mcu_PatientPreregisterID
|
||||
AND rs.Mcu_PatientResumeStatusMcuID = ?
|
||||
WHERE mp.Mcu_PatientMcuID = ?
|
||||
AND mp.Mcu_PatientIsActive = 'Y'
|
||||
ORDER BY
|
||||
rs.Mcu_PatientResumeStatusValidated DESC,
|
||||
mp.Mcu_PatientName ASC
|
||||
`, mcuID, mcuID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ProgressRow
|
||||
for rows.Next() {
|
||||
var r ProgressRow
|
||||
if err := rows.Scan(&r.PreregisterID, &r.NIP, &r.Name, &r.Posisi, &r.ResumeStatus, &r.Validated, &r.Published); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BuildProgressSummary(rows []ProgressRow) ProgressSummary {
|
||||
s := ProgressSummary{Total: len(rows)}
|
||||
for _, r := range rows {
|
||||
if r.Validated == "Y" {
|
||||
s.Validated++
|
||||
}
|
||||
if r.Published == "Y" {
|
||||
s.Published++
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func FilterProgressRows(rows []ProgressRow, search, status string) []ProgressRow {
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
status = strings.TrimSpace(status)
|
||||
if search == "" && status == "" {
|
||||
return rows
|
||||
}
|
||||
out := make([]ProgressRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
switch status {
|
||||
case "validated":
|
||||
if r.Validated != "Y" {
|
||||
continue
|
||||
}
|
||||
case "published":
|
||||
if r.Published != "Y" {
|
||||
continue
|
||||
}
|
||||
case "not_validated":
|
||||
if r.Validated == "Y" {
|
||||
continue
|
||||
}
|
||||
case "not_published":
|
||||
if r.Published == "Y" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if search != "" {
|
||||
hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi)
|
||||
if !strings.Contains(hay, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func Pct(num, total int) int {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return num * 100 / total
|
||||
}
|
||||
7
cpone-dashboard/menu/progress/route.go
Normal file
7
cpone-dashboard/menu/progress/route.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package progress
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/", Index)
|
||||
}
|
||||
71
cpone-dashboard/menu/projects/handler.go
Normal file
71
cpone-dashboard/menu/projects/handler.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var tmplFS *embed.FS
|
||||
var basePath string
|
||||
|
||||
func SetTemplateFS(fs *embed.FS) { tmplFS = fs }
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type pageData struct {
|
||||
Username string
|
||||
Projects []ProjectItem
|
||||
CurrentProject *ProjectItem
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
selectedID := auth.SelectedProjectID(r)
|
||||
|
||||
items, err := GetUserProjects(username)
|
||||
if err != nil {
|
||||
log.Printf("[projects] GetUserProjects error: %v", err)
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
b := func(path string) string { return basePath + path }
|
||||
t := template.Must(template.New("").Funcs(template.FuncMap{"b": b}).ParseFS(tmplFS, "templates/projects/index.html"))
|
||||
var current *ProjectItem
|
||||
if selectedID > 0 {
|
||||
if item, ok, _ := GetUserProject(username, selectedID); ok {
|
||||
current = &item
|
||||
}
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "projects", pageData{
|
||||
Username: username,
|
||||
Projects: items,
|
||||
CurrentProject: current,
|
||||
}); err != nil {
|
||||
log.Printf("[projects] template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Select(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
mcuID, _ := strconv.Atoi(r.URL.Query().Get("mcu_id"))
|
||||
if mcuID <= 0 {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok, err := GetUserProject(username, mcuID); err != nil {
|
||||
log.Printf("[projects] GetUserProject error: %v", err)
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if !ok {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
auth.SetSelectedProject(w, mcuID)
|
||||
http.Redirect(w, r, basePath+"/dashboard", http.StatusSeeOther)
|
||||
}
|
||||
93
cpone-dashboard/menu/projects/query.go
Normal file
93
cpone-dashboard/menu/projects/query.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"cpone-dashboard/menu/auth"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ProjectItem struct {
|
||||
McuID int
|
||||
Label string
|
||||
CorporateName string
|
||||
Number string
|
||||
StartDate string
|
||||
EndDate string
|
||||
TotalParticipant int
|
||||
}
|
||||
|
||||
func HasAccess(username string, mcuID int) (bool, error) {
|
||||
var count int
|
||||
err := db.DB.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM dashboard_user_project up
|
||||
JOIN dashboard_user u ON u.User_ID = up.UserProj_UserID
|
||||
WHERE u.User_Username = ?
|
||||
AND up.UserProj_McuID = ?
|
||||
AND up.UserProj_IsActive = 'Y'
|
||||
`, username, mcuID).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func GetUserProjects(username string) ([]ProjectItem, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
p.Mcu_ProjectMcuID,
|
||||
COALESCE(p.Mcu_ProjectLabel, ''),
|
||||
COALESCE(p.Mcu_ProjectCorporateName, ''),
|
||||
COALESCE(p.Mcu_ProjectNumber, ''),
|
||||
COALESCE(DATE_FORMAT(p.Mcu_ProjectStartDate, '%d/%m/%Y'), ''),
|
||||
COALESCE(DATE_FORMAT(p.Mcu_ProjectEndDate, '%d/%m/%Y'), ''),
|
||||
p.Mcu_ProjectTotalParticipant
|
||||
FROM dashboard_user_project up
|
||||
JOIN dashboard_user u ON u.User_ID = up.UserProj_UserID
|
||||
JOIN mcu_project p ON p.Mcu_ProjectMcuID = up.UserProj_McuID
|
||||
WHERE u.User_Username = ?
|
||||
AND up.UserProj_IsActive = 'Y'
|
||||
AND p.Mcu_ProjectIsActive = 'Y'
|
||||
ORDER BY p.Mcu_ProjectStartDate DESC
|
||||
`, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ProjectItem
|
||||
for rows.Next() {
|
||||
var item ProjectItem
|
||||
if err := rows.Scan(
|
||||
&item.McuID,
|
||||
&item.Label,
|
||||
&item.CorporateName,
|
||||
&item.Number,
|
||||
&item.StartDate,
|
||||
&item.EndDate,
|
||||
&item.TotalParticipant,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func GetUserProject(username string, mcuID int) (ProjectItem, bool, error) {
|
||||
items, err := GetUserProjects(username)
|
||||
if err != nil {
|
||||
return ProjectItem{}, false, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.McuID == mcuID {
|
||||
return item, true, nil
|
||||
}
|
||||
}
|
||||
return ProjectItem{}, false, nil
|
||||
}
|
||||
|
||||
func ResolveCurrentProject(username string, r *http.Request) (ProjectItem, bool, error) {
|
||||
selectedID := auth.SelectedProjectID(r)
|
||||
if selectedID == 0 {
|
||||
return ProjectItem{}, false, nil
|
||||
}
|
||||
return GetUserProject(username, selectedID)
|
||||
}
|
||||
8
cpone-dashboard/menu/projects/route.go
Normal file
8
cpone-dashboard/menu/projects/route.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package projects
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/", Index)
|
||||
r.Get("/select", Select)
|
||||
}
|
||||
74
cpone-dashboard/menu/result/handler.go
Normal file
74
cpone-dashboard/menu/result/handler.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package result
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"cpone-dashboard/menu/projects"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var tmpl *template.Template
|
||||
var pdfBaseURL string
|
||||
var basePath string
|
||||
|
||||
func SetTemplates(t *template.Template) { tmpl = t }
|
||||
func SetPDFBaseURL(u string) { pdfBaseURL = u }
|
||||
func SetBasePath(p string) { basePath = p }
|
||||
|
||||
type pageData struct {
|
||||
Username string
|
||||
CurrentProject projects.ProjectItem
|
||||
Search string
|
||||
Filter string
|
||||
Rows []ResultRow
|
||||
FilteredRows []ResultRow
|
||||
Summary ResultSummary
|
||||
PDFBaseURL string
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
mcuID := auth.SelectedProjectID(r)
|
||||
if mcuID == 0 {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
project, ok, err := projects.GetUserProject(username, mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := GetResultRows(mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
summary := BuildResultSummary(rows)
|
||||
search := r.URL.Query().Get("search")
|
||||
filter := r.URL.Query().Get("filter")
|
||||
filteredRows := FilterResultRows(rows, search, filter)
|
||||
|
||||
t := tmpl
|
||||
if t == nil {
|
||||
http.Error(w, "template not ready", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "base", pageData{
|
||||
Username: username,
|
||||
CurrentProject: project,
|
||||
Search: search,
|
||||
Filter: filter,
|
||||
Rows: rows,
|
||||
FilteredRows: filteredRows,
|
||||
Summary: summary,
|
||||
PDFBaseURL: pdfBaseURL,
|
||||
}); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
101
cpone-dashboard/menu/result/query.go
Normal file
101
cpone-dashboard/menu/result/query.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package result
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ResultRow struct {
|
||||
NIP string
|
||||
Name string
|
||||
Posisi string
|
||||
FileUrl string
|
||||
ReportDate string
|
||||
}
|
||||
|
||||
type ResultSummary struct {
|
||||
Total int
|
||||
HasPDF int
|
||||
}
|
||||
|
||||
func GetResultRows(mcuID int) ([]ResultRow, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '-') AS nip,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '-') AS name,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS posisi,
|
||||
COALESCE(p.Published_McuDasboardFileUrl, '') AS file_url,
|
||||
CASE
|
||||
WHEN p.Published_McuDasboardFileUrl IS NOT NULL
|
||||
AND p.Published_McuDasboardFileUrl != ''
|
||||
THEN COALESCE(CAST(p.Published_McuDasboardLastUpdated AS CHAR), '')
|
||||
ELSE ''
|
||||
END AS report_date
|
||||
FROM mcu_patient mp
|
||||
LEFT JOIN published_mcu_dashboard_sync p
|
||||
ON p.Published_McuDasboardT_OrderHeaderID = mp.Mcu_PatientOrderID
|
||||
WHERE mp.Mcu_PatientMcuID = ?
|
||||
AND mp.Mcu_PatientIsActive = 'Y'
|
||||
ORDER BY
|
||||
(p.Published_McuDasboardFileUrl IS NOT NULL AND p.Published_McuDasboardFileUrl != '') DESC,
|
||||
mp.Mcu_PatientName ASC
|
||||
`, mcuID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ResultRow
|
||||
for rows.Next() {
|
||||
var r ResultRow
|
||||
if err := rows.Scan(&r.NIP, &r.Name, &r.Posisi, &r.FileUrl, &r.ReportDate); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func BuildResultSummary(rows []ResultRow) ResultSummary {
|
||||
s := ResultSummary{Total: len(rows)}
|
||||
for _, r := range rows {
|
||||
if r.FileUrl != "" {
|
||||
s.HasPDF++
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func FilterResultRows(rows []ResultRow, search, filter string) []ResultRow {
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
filter = strings.TrimSpace(filter)
|
||||
if search == "" && filter == "" {
|
||||
return rows
|
||||
}
|
||||
out := make([]ResultRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
switch filter {
|
||||
case "has_pdf":
|
||||
if r.FileUrl == "" {
|
||||
continue
|
||||
}
|
||||
case "no_pdf":
|
||||
if r.FileUrl != "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if search != "" {
|
||||
hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi)
|
||||
if !strings.Contains(hay, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
7
cpone-dashboard/menu/result/route.go
Normal file
7
cpone-dashboard/menu/result/route.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package result
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func Routes(r chi.Router) {
|
||||
r.Get("/", Index)
|
||||
}
|
||||
149
cpone-dashboard/scripts/README.md
Normal file
149
cpone-dashboard/scripts/README.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Demo Scripts — MCU PROJECT DEMO 2026
|
||||
|
||||
Script untuk generate dummy data dan simulasi live MCU berjalan, khusus keperluan demo ke client.
|
||||
|
||||
---
|
||||
|
||||
## Struktur
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── demo_seed.py # Generate + jalankan dummy data (1500 pasien, historis + hari ini)
|
||||
├── demo_live.sh # Simulasi aktivitas MCU berjalan secara real-time
|
||||
├── demo_cleanup.sql # Hapus semua data demo
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Seed data awal (jalankan sekali sebelum demo)
|
||||
|
||||
```bash
|
||||
# Di laptop lokal
|
||||
python3 scripts/demo_seed.py > /tmp/demo_seed.sql
|
||||
|
||||
# Upload + jalankan di server
|
||||
scp /tmp/demo_seed.sql one@devcpone.aplikasi.web.id:/tmp/
|
||||
ssh one@devcpone.aplikasi.web.id "mysql -u admin -pSasone\!102938 cpone_dashboard < /tmp/demo_seed.sql"
|
||||
```
|
||||
|
||||
Alternatif one-liner:
|
||||
```bash
|
||||
python3 scripts/demo_seed.py | ssh one@devcpone.aplikasi.web.id "mysql -u admin -pSasone\!102938 cpone_dashboard"
|
||||
```
|
||||
|
||||
### 2. Jalankan live simulation
|
||||
|
||||
```bash
|
||||
ssh one@devcpone.aplikasi.web.id
|
||||
|
||||
# Kecepatan normal (8 detik per aksi)
|
||||
/home/one/demo_live.sh
|
||||
|
||||
# Lebih cepat untuk demo dinamis (3 detik per aksi)
|
||||
/home/one/demo_live.sh 3
|
||||
|
||||
# Lebih lambat (15 detik per aksi)
|
||||
/home/one/demo_live.sh 15
|
||||
|
||||
# Hentikan: Ctrl+C
|
||||
```
|
||||
|
||||
### 3. Reset data (setelah demo selesai)
|
||||
|
||||
```bash
|
||||
ssh one@devcpone.aplikasi.web.id "mysql -u admin -pSasone\!102938 cpone_dashboard < /tmp/demo_cleanup.sql"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data yang Di-seed
|
||||
|
||||
| | Detail |
|
||||
|---|---|
|
||||
| **Project** | MCU PROJECT DEMO 2026 — PT DEMO CORPORATION |
|
||||
| **MCU ID** | 9999 |
|
||||
| **Periode** | 20 April 2026 – 10 Mei 2026 |
|
||||
| **Total pasien** | 1.500 (75/hari selama 20 hari) |
|
||||
| **Preregister ID range** | 900001 – 901500 |
|
||||
|
||||
### Distribusi skenario
|
||||
|
||||
| Tanggal | Pasien | Skenario |
|
||||
|---------|--------|----------|
|
||||
| 20 Apr – 28 Apr | 1 – 675 | Sudah selesai semua. Di antara ini, setiap kelipatan 15 (patient ke-15, 30, …) melakukan **2-day check-in**: sebagian station hari 1, sisa station hari 2. |
|
||||
| 29 Apr | 676 – 750 | Sudah check-in, nonlab station selesai, **lab station masih processing**. |
|
||||
| 30 Apr (hari ini) | 751 – 825 | 751–810: sudah check-in pagi, **partial station** (sedang berjalan). 811–825: **belum datang** — inilah yang akan di-trigger live script. |
|
||||
| 1 Mei – 9 Mei | 826 – 1.500 | Belum dijadwalkan. |
|
||||
|
||||
### Resume & hasil
|
||||
|
||||
| Kondisi | Pasien |
|
||||
|---------|--------|
|
||||
| Resume validated ✅ | Apr 20–27 (600 pasien) |
|
||||
| Resume published ✅ | Apr 20–25 (375 pasien) |
|
||||
| PDF tersedia (View PDF) | Pasien 1–80 (`2026/04/R26040001_*.pdf` dst.) |
|
||||
| Kelainan / abnormal | ~33% dari pasien validated (~186 rows), 6 grup |
|
||||
|
||||
---
|
||||
|
||||
## Apa yang Disimulasikan live_demo.sh
|
||||
|
||||
Script berjalan infinite loop, cycle 4 aksi secara berurutan:
|
||||
|
||||
| Aksi | Yang Terjadi |
|
||||
|------|-------------|
|
||||
| **A — Check-in** | Pasien baru dari kelompok "belum datang" (811–825) melakukan check-in dengan waktu saat ini |
|
||||
| **B — Station** | Pasien yang sudah check-in hari ini mendapatkan update station selesai (satu station per siklus) |
|
||||
| **C — Validasi** | Resume pasien kemarin (Apr 29) divalidasi dokter satu per satu |
|
||||
| **D — Publish PDF** | Pasien Apr 29 yang sudah divalidasi mendapat file PDF dan statusnya published |
|
||||
|
||||
Output di terminal:
|
||||
```
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ CpOne — DEMO LIVE SIMULATION ║
|
||||
║ MCU PROJECT DEMO 2026 │ ID: 9999 │ Hari ini: 2026-04-30 ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Aksi: A=Check-in B=Station C=Validasi D=Publish PDF
|
||||
Interval: 8s │ Press Ctrl+C untuk berhenti
|
||||
|
||||
[10:32:01] ✅ CHECK-IN │ Siti Santoso │ HR │ masuk pukul 10:32:01
|
||||
[10:32:09] 🏥 STATION │ Budi Wijaya │ Sample Station ECG │ selesai
|
||||
[10:32:17] ✔ VALIDASI │ Rina Pratama │ resume divalidasi dokter
|
||||
[10:32:25] 📄 PUBLISH │ Agus Kurnia │ PDF → 2026/04/R2604D76_resume_individu.pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips Demo
|
||||
|
||||
**Halaman yang menarik untuk ditunjukkan:**
|
||||
|
||||
1. **Dashboard** — pilih tanggal hari ini (30/04/2026) untuk lihat aktivitas real-time
|
||||
2. **Arrival** — scroll ke bawah, pasien baru akan muncul setiap siklus
|
||||
3. **Progress** — counter "Validated" dan "Published" naik setiap kali aksi C/D dijalankan
|
||||
4. **Abnormal** — sudah ada data kelainan dari pasien Apr 20–27
|
||||
5. **Result** — tombol "View PDF" aktif untuk pasien 1–80 + pasien yang baru di-publish
|
||||
|
||||
**Saat demo:**
|
||||
- Buka dashboard di browser, biarkan live script jalan di background terminal
|
||||
- Tunjukkan pasien check-in datang satu per satu (Arrival page)
|
||||
- Tunjukkan station selesai bertahap (Dashboard → Station Status)
|
||||
- Tunjukkan counter validated/published naik (Progress page)
|
||||
- Tunjukkan PDF bisa dibuka (Result page → tombol View PDF)
|
||||
|
||||
---
|
||||
|
||||
## Re-seed Ulang
|
||||
|
||||
Kalau data ingin di-reset ke kondisi awal (misal setelah demo):
|
||||
|
||||
```bash
|
||||
# Dari laptop lokal, jalankan ulang seed
|
||||
python3 scripts/demo_seed.py | ssh one@devcpone.aplikasi.web.id "mysql -u admin -pSasone\!102938 cpone_dashboard"
|
||||
```
|
||||
|
||||
Script seed sudah idempotent — DELETE data lama dulu sebelum INSERT baru.
|
||||
19
cpone-dashboard/scripts/demo_cleanup.sql
Normal file
19
cpone-dashboard/scripts/demo_cleanup.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- demo_cleanup.sql — Remove all demo data (MCU ID=9999)
|
||||
-- Usage: mysql -u admin -pSasone!102938 cpone_dashboard < demo_cleanup.sql
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
DELETE FROM mcu_station_progress WHERE Mcu_StationProgressMcuID = 9999;
|
||||
DELETE FROM mcu_checkinout WHERE Mcu_CheckinoutMcuID = 9999;
|
||||
DELETE FROM mcu_patient_required_station WHERE mcu_id = 9999;
|
||||
DELETE FROM mcu_patient_schedule WHERE Mcu_PatientSchedulePreregisterID BETWEEN 900001 AND 901500;
|
||||
DELETE FROM mcu_patient_resume_status WHERE Mcu_PatientResumeStatusMcuID = 9999;
|
||||
DELETE FROM published_mcu_dashboard_sync WHERE Published_McuDasboardT_OrderHeaderID BETWEEN 900001 AND 901500;
|
||||
DELETE FROM kelainan_details WHERE Mgm_McuID = 9999;
|
||||
DELETE FROM mcu_participant_daily WHERE Mcu_ParticipantDailyMcuID = 9999;
|
||||
DELETE FROM mcu_patient WHERE Mcu_PatientMcuID = 9999;
|
||||
DELETE FROM mcu_project WHERE Mcu_ProjectMcuID = 9999;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
SELECT 'Demo data removed.' AS status;
|
||||
293
cpone-dashboard/scripts/demo_live.sh
Normal file
293
cpone-dashboard/scripts/demo_live.sh
Normal file
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env bash
|
||||
# demo_live.sh — Simulate live ongoing MCU activity for demo
|
||||
#
|
||||
# Usage:
|
||||
# ./demo_live.sh # default: 1 action every 8 seconds
|
||||
# ./demo_live.sh 3 # 1 action every 3 seconds (faster)
|
||||
# ./demo_live.sh 15 # 1 action every 15 seconds (slower)
|
||||
#
|
||||
# What it simulates (cycles through 4 action types):
|
||||
# A — New patient check-in for today (April 30, patients 811–825)
|
||||
# B — Station progress update for today's in-progress patients
|
||||
# C — Doctor validates a yesterday resume (April 29)
|
||||
# D — Publish result PDF (April 29 patient gets a file URL)
|
||||
#
|
||||
# Press Ctrl+C to stop.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MCU_ID=9999
|
||||
TODAY="2026-04-30"
|
||||
SPEED="${1:-8}"
|
||||
|
||||
DB() { mysql -u admin -p'Sasone!102938' cpone_dashboard -sN -e "$1" 2>/dev/null; }
|
||||
DB_EXEC() { mysql -u admin -p'Sasone!102938' cpone_dashboard -e "$1" 2>/dev/null; }
|
||||
|
||||
PDF_BASE_DIR="/home/one/project/one/dashboard-files/2026/04"
|
||||
PDF_SOURCE="/home/one/project/one/dashboard-files/2024/09/R2409170003_resume_individu.pdf"
|
||||
PDF_DB_PATH="2026/04"
|
||||
|
||||
NOW_TS() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||
LOG() { printf "[%s] %s\n" "$(date '+%H:%M:%S')" "$1"; }
|
||||
|
||||
# ── counters ──────────────────────────────────────────────────────────────────
|
||||
action_cycle=0
|
||||
|
||||
# ── ACTION A: new check-in ────────────────────────────────────────────────────
|
||||
action_checkin() {
|
||||
local patient
|
||||
patient=$(DB "
|
||||
SELECT mp.Mcu_PatientPreregisterID
|
||||
FROM mcu_patient mp
|
||||
WHERE mp.Mcu_PatientMcuID = $MCU_ID
|
||||
AND mp.Mcu_PatientPreregisterID NOT IN (
|
||||
SELECT Mcu_CheckinoutPreregisterID
|
||||
FROM mcu_checkinout
|
||||
WHERE Mcu_CheckinoutMcuID = $MCU_ID
|
||||
AND Mcu_CheckinoutDate = '$TODAY'
|
||||
)
|
||||
AND mp.Mcu_PatientPreregisterID BETWEEN 900811 AND 900825
|
||||
ORDER BY mp.Mcu_PatientPreregisterID
|
||||
LIMIT 1;
|
||||
")
|
||||
|
||||
if [ -z "$patient" ]; then
|
||||
LOG "⏩ Semua pasien hari ini sudah check-in"
|
||||
return
|
||||
fi
|
||||
|
||||
local pid=$patient
|
||||
local oid=$patient
|
||||
local name
|
||||
name=$(DB "SELECT Mcu_PatientName FROM mcu_patient WHERE Mcu_PatientPreregisterID = $pid;")
|
||||
local dept
|
||||
dept=$(DB "SELECT Mcu_PatientDepartment FROM mcu_patient WHERE Mcu_PatientPreregisterID = $pid;")
|
||||
local now_time
|
||||
now_time=$(date '+%H:%M:%S')
|
||||
|
||||
DB_EXEC "
|
||||
INSERT INTO mcu_checkinout
|
||||
(Mcu_CheckinoutMcuID, Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
|
||||
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
|
||||
Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt)
|
||||
VALUES
|
||||
($MCU_ID, $pid, $oid, '$TODAY', '$now_time', NULL, 'Y', NOW());
|
||||
"
|
||||
LOG "✅ CHECK-IN │ $name │ $dept │ masuk pukul $now_time"
|
||||
}
|
||||
|
||||
# ── ACTION B: station progress update ────────────────────────────────────────
|
||||
action_station() {
|
||||
# Pick a patient checked in today with at least one required station not yet done
|
||||
# Station IDs that are part of the MCU package
|
||||
local ALL_STATIONS="1,31,17,4,5,2,7,33"
|
||||
local patient
|
||||
patient=$(DB "
|
||||
SELECT c.Mcu_CheckinoutPreregisterID
|
||||
FROM mcu_checkinout c
|
||||
WHERE c.Mcu_CheckinoutMcuID = $MCU_ID
|
||||
AND c.Mcu_CheckinoutDate = '$TODAY'
|
||||
AND (
|
||||
SELECT COUNT(DISTINCT sp.Mcu_StationProgressStationID)
|
||||
FROM mcu_station_progress sp
|
||||
WHERE sp.Mcu_StationProgressPreregisterID = c.Mcu_CheckinoutPreregisterID
|
||||
AND sp.Mcu_StationProgressMcuID = $MCU_ID
|
||||
AND sp.Mcu_StationProgressStationID IN ($ALL_STATIONS)
|
||||
) < 8
|
||||
ORDER BY c.Mcu_CheckinoutPreregisterID
|
||||
LIMIT 1;
|
||||
")
|
||||
|
||||
if [ -z "$patient" ]; then
|
||||
LOG "⏩ Semua station sudah diproses untuk pasien hari ini"
|
||||
return
|
||||
fi
|
||||
|
||||
local pid=$patient
|
||||
local oid=$patient
|
||||
local name
|
||||
name=$(DB "SELECT Mcu_PatientName FROM mcu_patient WHERE Mcu_PatientPreregisterID = $pid;")
|
||||
|
||||
# Get next station not yet done (from the 8-station package)
|
||||
local station_row
|
||||
station_row=$(DB "
|
||||
SELECT s.sid, s.sname, s.src
|
||||
FROM (
|
||||
SELECT 1 AS sid, 'Sample Station Phlebotomy' AS sname, 'lab' AS src UNION ALL
|
||||
SELECT 31,'Sample Station Urine','lab' UNION ALL
|
||||
SELECT 17,'Sample Station TB/BB','nonlab' UNION ALL
|
||||
SELECT 4, 'Sample Station Treadmill','nonlab' UNION ALL
|
||||
SELECT 5, 'Sample Station ECG','nonlab' UNION ALL
|
||||
SELECT 2, 'Sample Station Rontgen','nonlab' UNION ALL
|
||||
SELECT 7, 'Sample Station Pemeriksaan Fisik','nonlab' UNION ALL
|
||||
SELECT 33,'Sample Station Visus dan Buta Warna','nonlab'
|
||||
) s
|
||||
WHERE s.sid NOT IN (
|
||||
SELECT Mcu_StationProgressStationID
|
||||
FROM mcu_station_progress
|
||||
WHERE Mcu_StationProgressPreregisterID = $pid
|
||||
AND Mcu_StationProgressMcuID = $MCU_ID
|
||||
)
|
||||
LIMIT 1;
|
||||
")
|
||||
|
||||
if [ -z "$station_row" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local sid sname src
|
||||
sid=$(echo "$station_row" | cut -f1)
|
||||
sname=$(echo "$station_row" | cut -f2)
|
||||
src=$(echo "$station_row" | cut -f3)
|
||||
local now_ts
|
||||
now_ts=$(NOW_TS)
|
||||
|
||||
if [ "$src" = "lab" ]; then
|
||||
DB_EXEC "
|
||||
INSERT INTO mcu_station_progress
|
||||
(Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
|
||||
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
|
||||
Mcu_StationProgressCheckinDate, Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt,
|
||||
Mcu_StationProgressProcessAt, Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt)
|
||||
VALUES
|
||||
($oid, $pid, $MCU_ID, $sid, '$sname', 'lab',
|
||||
'$TODAY',
|
||||
DATE_SUB('$now_ts', INTERVAL 45 MINUTE),
|
||||
DATE_SUB('$now_ts', INTERVAL 30 MINUTE),
|
||||
DATE_SUB('$now_ts', INTERVAL 10 MINUTE),
|
||||
'$now_ts', NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_StationProgressDoneAt = '$now_ts',
|
||||
Mcu_StationProgressSyncedAt = NOW();
|
||||
"
|
||||
else
|
||||
DB_EXEC "
|
||||
INSERT INTO mcu_station_progress
|
||||
(Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
|
||||
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
|
||||
Mcu_StationProgressCheckinDate, Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt,
|
||||
Mcu_StationProgressProcessAt, Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt)
|
||||
VALUES
|
||||
($oid, $pid, $MCU_ID, $sid, '$sname', 'nonlab',
|
||||
'$TODAY', NULL, NULL,
|
||||
DATE_SUB('$now_ts', INTERVAL 15 MINUTE),
|
||||
'$now_ts', NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
Mcu_StationProgressDoneAt = '$now_ts',
|
||||
Mcu_StationProgressSyncedAt = NOW();
|
||||
"
|
||||
fi
|
||||
LOG "🏥 STATION │ $name │ $sname │ selesai"
|
||||
}
|
||||
|
||||
# ── ACTION C: validate resume ─────────────────────────────────────────────────
|
||||
action_validate() {
|
||||
local patient
|
||||
patient=$(DB "
|
||||
SELECT rs.Mcu_PatientResumeStatusPreregisterID
|
||||
FROM mcu_patient_resume_status rs
|
||||
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = rs.Mcu_PatientResumeStatusPreregisterID
|
||||
WHERE rs.Mcu_PatientResumeStatusMcuID = $MCU_ID
|
||||
AND rs.Mcu_PatientResumeStatusValidated = 'N'
|
||||
AND mp.Mcu_PatientPreregisterID BETWEEN 900676 AND 900750
|
||||
ORDER BY rs.Mcu_PatientResumeStatusPreregisterID
|
||||
LIMIT 1;
|
||||
")
|
||||
|
||||
if [ -z "$patient" ]; then
|
||||
LOG "⏩ Semua resume kemarin sudah divalidasi"
|
||||
return
|
||||
fi
|
||||
|
||||
local name
|
||||
name=$(DB "SELECT Mcu_PatientName FROM mcu_patient WHERE Mcu_PatientPreregisterID = $patient;")
|
||||
|
||||
DB_EXEC "
|
||||
UPDATE mcu_patient_resume_status
|
||||
SET Mcu_PatientResumeStatusValidated = 'Y',
|
||||
Mcu_PatientResumeStatusStatus = 'DONE',
|
||||
Mcu_PatientResumeSyncedAt = NOW()
|
||||
WHERE Mcu_PatientResumeStatusPreregisterID = $patient
|
||||
AND Mcu_PatientResumeStatusMcuID = $MCU_ID;
|
||||
"
|
||||
LOG "✔ VALIDASI │ $name │ resume divalidasi dokter"
|
||||
}
|
||||
|
||||
# ── ACTION D: publish PDF ─────────────────────────────────────────────────────
|
||||
action_publish() {
|
||||
local order_id
|
||||
order_id=$(DB "
|
||||
SELECT rs.Mcu_PatientResumeStatusPreregisterID
|
||||
FROM mcu_patient_resume_status rs
|
||||
WHERE rs.Mcu_PatientResumeStatusMcuID = $MCU_ID
|
||||
AND rs.Mcu_PatientResumeStatusValidated = 'Y'
|
||||
AND rs.Mcu_PatientResumeStatusPublished = 'N'
|
||||
AND rs.Mcu_PatientResumeStatusPreregisterID BETWEEN 900676 AND 900825
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM published_mcu_dashboard_sync p
|
||||
WHERE p.Published_McuDasboardT_OrderHeaderID = rs.Mcu_PatientResumeStatusPreregisterID
|
||||
AND (p.Published_McuDasboardFileUrl IS NULL OR p.Published_McuDasboardFileUrl = '')
|
||||
)
|
||||
ORDER BY rs.Mcu_PatientResumeStatusPreregisterID
|
||||
LIMIT 1;
|
||||
")
|
||||
|
||||
if [ -z "$order_id" ]; then
|
||||
LOG "⏩ Tidak ada resume validated yang bisa dipublish saat ini"
|
||||
return
|
||||
fi
|
||||
|
||||
local name
|
||||
name=$(DB "SELECT Mcu_PatientName FROM mcu_patient WHERE Mcu_PatientPreregisterID = $order_id;")
|
||||
|
||||
# seq number for PDF filename: use order_id offset
|
||||
local seq=$(( order_id - 900000 ))
|
||||
local fname="R2604D${seq}_resume_individu.pdf"
|
||||
local fpath="$PDF_BASE_DIR/$fname"
|
||||
local db_path="$PDF_DB_PATH/$fname"
|
||||
|
||||
# Copy PDF file
|
||||
mkdir -p "$PDF_BASE_DIR"
|
||||
cp "$PDF_SOURCE" "$fpath" 2>/dev/null || true
|
||||
|
||||
# Update database
|
||||
DB_EXEC "
|
||||
UPDATE published_mcu_dashboard_sync
|
||||
SET Published_McuDasboardFileUrl = '$db_path',
|
||||
Published_McuDasboardStatus = 'Y',
|
||||
Published_McuDasboardLastUpdated = NOW()
|
||||
WHERE Published_McuDasboardT_OrderHeaderID = $order_id;
|
||||
|
||||
UPDATE mcu_patient_resume_status
|
||||
SET Mcu_PatientResumeStatusPublished = 'Y',
|
||||
Mcu_PatientResumeSyncedAt = NOW()
|
||||
WHERE Mcu_PatientResumeStatusPreregisterID = $order_id
|
||||
AND Mcu_PatientResumeStatusMcuID = $MCU_ID;
|
||||
"
|
||||
LOG "📄 PUBLISH │ $name │ PDF → $db_path"
|
||||
}
|
||||
|
||||
# ── main loop ─────────────────────────────────────────────────────────────────
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ CpOne — DEMO LIVE SIMULATION ║"
|
||||
echo "║ MCU PROJECT DEMO 2026 │ ID: $MCU_ID │ Hari ini: $TODAY ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo " Aksi: A=Check-in B=Station C=Validasi D=Publish PDF"
|
||||
echo " Interval: ${SPEED}s │ Ctrl+C untuk berhenti"
|
||||
echo ""
|
||||
|
||||
actions=(A B C D)
|
||||
idx=0
|
||||
|
||||
while true; do
|
||||
act="${actions[$((idx % 4))]}"
|
||||
case "$act" in
|
||||
A) action_checkin ;;
|
||||
B) action_station ;;
|
||||
C) action_validate ;;
|
||||
D) action_publish ;;
|
||||
esac
|
||||
idx=$(( idx + 1 ))
|
||||
sleep "$SPEED"
|
||||
done
|
||||
417
cpone-dashboard/scripts/demo_seed.py
Normal file
417
cpone-dashboard/scripts/demo_seed.py
Normal file
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
demo_seed.py — Generate demo data for MCU PROJECT DEMO 2026 (ID=9999)
|
||||
|
||||
Usage: python3 demo_seed.py | mysql -u admin -pSasone!102938 cpone_dashboard
|
||||
|
||||
Data distribution:
|
||||
- 1500 patients, 75/day over April 20 – May 9, 2026
|
||||
- April 20–28 (days 0–8, patients 1–675): fully completed MCU
|
||||
- every 15th patient: 2-day checkin
|
||||
- April 29 (day 9, patients 676–750): checked in, most stations done
|
||||
- a few lab stations still processing
|
||||
- April 30 (day 10, patients 751–825): ongoing today
|
||||
- 751–810 (60 patients): checked in this morning, partial stations
|
||||
- 811–825 (15 patients): not yet arrived
|
||||
- May 1–9 (days 11–19, patients 826–1500): not yet
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
# ── constants ──────────────────────────────────────────────────────────────────
|
||||
MCU_ID = 9999
|
||||
BASE_ID = 900000 # preregister_id = BASE_ID + i (900001–901500)
|
||||
START_DATE = date(2026, 4, 20)
|
||||
TODAY = date(2026, 4, 30)
|
||||
N_PATIENTS = 1500
|
||||
PER_DAY = 75
|
||||
|
||||
MALE_FIRST = ['Budi','Agus','Deni','Rizki','Hendra','Andi','Fajar','Rendi',
|
||||
'Wahyu','Dito','Bagas','Kevin','Andre','Yoga','Raka','Faisal',
|
||||
'Eko','Iwan','Joko','Teguh']
|
||||
FEMALE_FIRST= ['Siti','Ani','Dewi','Rina','Fitri','Nurul','Lilis','Putri',
|
||||
'Nadya','Citra','Alya','Sinta','Nabila','Dian','Reni','Maya',
|
||||
'Ira','Yuni','Wati','Tini']
|
||||
LAST_NAMES = ['Santoso','Wijaya','Pratama','Kurnia','Saputra','Wulandari',
|
||||
'Lestari','Nugroho','Halim','Sari','Rahman','Hidayat',
|
||||
'Firmansyah','Kusuma','Raharjo','Setiawan','Wahyudi','Susanto',
|
||||
'Purnomo','Hakim','Mulyadi','Astuti','Permata','Handoko','Budiman']
|
||||
DEPTS = ['HR','Finance','Production','Engineering','IT','QA','Procurement',
|
||||
'Warehouse','Security','Marketing','Legal','Operations','HSE',
|
||||
'Maintenance','Accounting','Sales','Logistics','R&D','Admin','Management']
|
||||
POSITIONS = ['Staff','Supervisor','Manager','Operator','Technician','Analyst',
|
||||
'Coordinator','Head','Specialist','Officer','Inspector','Foreman',
|
||||
'Executive','Director','Senior Staff']
|
||||
|
||||
# stations: (station_id, name, source)
|
||||
STATIONS = [
|
||||
(1, 'Sample Station Phlebotomy', 'lab'),
|
||||
(31, 'Sample Station Urine', 'lab'),
|
||||
(17, 'Sample Station TB/BB', 'nonlab'),
|
||||
(4, 'Sample Station Treadmill', 'nonlab'),
|
||||
(5, 'Sample Station ECG', 'nonlab'),
|
||||
(2, 'Sample Station Rontgen', 'nonlab'),
|
||||
(7, 'Sample Station Pemeriksaan Fisik', 'nonlab'),
|
||||
(33, 'Sample Station Visus dan Buta Warna','nonlab'),
|
||||
]
|
||||
|
||||
KELAINAN_GROUPS = [
|
||||
('BMI', 'BMI Abnormal', 0.28),
|
||||
('Hipertensi', 'Hipertensi', 0.15),
|
||||
('Kelainan Visus', 'Kelainan Refraksi', 0.20),
|
||||
('Ganguan Metabolisme Lemak', 'Dislipidemia', 0.12),
|
||||
('Kelainan Hematologi', 'Hemoglobin Rendah', 0.08),
|
||||
('Kelainan Fisik', 'Kelainan Muskuloskeletal', 0.10),
|
||||
]
|
||||
|
||||
FITNESS_CATS = {
|
||||
'BMI': ('Fit dengan Catatan', 'Fit with Note', 2),
|
||||
'Hipertensi': ('Tidak Fit Sementara','Temporary Unfit',3),
|
||||
'Kelainan Visus': ('Fit dengan Catatan', 'Fit with Note', 2),
|
||||
'Ganguan Metabolisme Lemak': ('Fit dengan Catatan', 'Fit with Note', 2),
|
||||
'Kelainan Hematologi': ('Fit dengan Catatan', 'Fit with Note', 2),
|
||||
'Kelainan Fisik': ('Fit dengan Catatan', 'Fit with Note', 2),
|
||||
}
|
||||
|
||||
PDF_BASE = '2026/04'
|
||||
PDF_COUNT = 80 # patients 1–80 get a PDF file
|
||||
|
||||
lines = []
|
||||
|
||||
def q(s):
|
||||
if s is None:
|
||||
return 'NULL'
|
||||
return "'" + str(s).replace("'", "''") + "'"
|
||||
|
||||
def dt(d, h, m, extra_min=0):
|
||||
"""Return DATETIME string for date d, time h:m plus extra_min."""
|
||||
base = datetime(d.year, d.month, d.day, h, m)
|
||||
base += timedelta(minutes=extra_min)
|
||||
return base.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# ── header ─────────────────────────────────────────────────────────────────────
|
||||
lines.append("SET FOREIGN_KEY_CHECKS = 0;")
|
||||
lines.append("SET @mcu = 9999;")
|
||||
lines.append("")
|
||||
|
||||
# ── cleanup ────────────────────────────────────────────────────────────────────
|
||||
lines.append("-- cleanup existing demo data")
|
||||
lines.append("DELETE FROM mcu_station_progress WHERE Mcu_StationProgressMcuID = 9999;")
|
||||
lines.append("DELETE FROM mcu_checkinout WHERE Mcu_CheckinoutMcuID = 9999;")
|
||||
lines.append("DELETE FROM mcu_patient_required_station WHERE preregister_id BETWEEN 900001 AND 901500;")
|
||||
lines.append("DELETE FROM mcu_patient_schedule WHERE Mcu_PatientSchedulePreregisterID BETWEEN 900001 AND 901500;")
|
||||
lines.append("DELETE FROM mcu_patient_resume_status WHERE Mcu_PatientResumeStatusMcuID = 9999;")
|
||||
lines.append("DELETE FROM published_mcu_dashboard_sync WHERE Published_McuDasboardT_OrderHeaderID BETWEEN 900001 AND 901500;")
|
||||
lines.append("DELETE FROM kelainan_details WHERE Mgm_McuID = 9999;")
|
||||
lines.append("DELETE FROM mcu_participant_daily WHERE Mcu_ParticipantDailyMcuID = 9999;")
|
||||
lines.append("DELETE FROM mcu_patient WHERE Mcu_PatientMcuID = 9999;")
|
||||
lines.append("DELETE FROM mcu_project WHERE Mcu_ProjectMcuID = 9999;")
|
||||
lines.append("")
|
||||
|
||||
# ── project ────────────────────────────────────────────────────────────────────
|
||||
lines.append("-- project")
|
||||
lines.append(
|
||||
"INSERT INTO mcu_project "
|
||||
"(Mcu_ProjectMcuID, Mcu_ProjectCorporateID, Mcu_ProjectCorporateName, "
|
||||
" Mcu_ProjectNumber, Mcu_ProjectLabel, Mcu_ProjectBranchID, "
|
||||
" Mcu_ProjectStartDate, Mcu_ProjectEndDate, "
|
||||
" Mcu_ProjectIsActive, Mcu_ProjectTotalParticipant, Mcu_ProjectSyncedAt) "
|
||||
"VALUES "
|
||||
f"(9999, 9999, 'PT DEMO CORPORATION', 'MCU-DEMO-2026', 'MCU PROJECT DEMO 2026', "
|
||||
f"1, '2026-04-20', '2026-05-10', 'Y', 1500, NOW());"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── participant daily ──────────────────────────────────────────────────────────
|
||||
lines.append("-- participant daily totals")
|
||||
daily_rows = []
|
||||
for day_idx in range(20):
|
||||
d = START_DATE + timedelta(days=day_idx)
|
||||
daily_rows.append(
|
||||
f"(9999, '{d}', {PER_DAY}, 'Y', NOW(), NOW())"
|
||||
)
|
||||
lines.append(
|
||||
"INSERT INTO mcu_participant_daily "
|
||||
"(Mcu_ParticipantDailyMcuID, Mcu_ParticipantDailyDate, Mcu_ParticipantDailyTotal, "
|
||||
" Mcu_ParticipantDailyIsActive, Mcu_ParticipantDailyCreated, Mcu_ParticipantDailyLastUpdated) "
|
||||
"VALUES\n " + ",\n ".join(daily_rows) + ";"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# ── patients, schedules, required stations, checkinout, stations ───────────────
|
||||
patient_rows = []
|
||||
schedule_rows = []
|
||||
req_station_rows = [] # location_id 1–8 per station, unique per (preregister_id, location_id)
|
||||
checkin_rows = []
|
||||
station_rows = []
|
||||
resume_rows = []
|
||||
published_rows = []
|
||||
kelainan_rows = []
|
||||
|
||||
for i in range(1, N_PATIENTS + 1):
|
||||
pid = BASE_ID + i # preregister_id
|
||||
oid = BASE_ID + i # order_id (same for simplicity)
|
||||
day_idx = (i - 1) // PER_DAY
|
||||
sched_date = START_DATE + timedelta(days=day_idx)
|
||||
|
||||
# name / gender
|
||||
g_idx = (i - 1) % 40
|
||||
if g_idx < 20:
|
||||
gender = 'Male'
|
||||
first = MALE_FIRST[g_idx % len(MALE_FIRST)]
|
||||
else:
|
||||
gender = 'Female'
|
||||
first = FEMALE_FIRST[(g_idx - 20) % len(FEMALE_FIRST)]
|
||||
last = LAST_NAMES[(i + 7) % len(LAST_NAMES)]
|
||||
name = f"{first} {last}"
|
||||
nip = f"EMP{pid}"
|
||||
dept = DEPTS[(i + 3) % len(DEPTS)]
|
||||
posisi = POSITIONS[i % len(POSITIONS)]
|
||||
age = 22 + ((i * 3 + 7) % 34) # 22–55
|
||||
dob = date(2026 - age, 1, 1).isoformat()
|
||||
lab_no = f"R2604{i:04d}"
|
||||
|
||||
patient_rows.append(
|
||||
f"({pid}, {MCU_ID}, {q(name)}, {q(nip)}, {q(gender)}, "
|
||||
f"{q(dob)}, {age}, {q(dept)}, {q(posisi)}, "
|
||||
f"{oid}, 'Y', 'Y', NOW())"
|
||||
)
|
||||
|
||||
schedule_rows.append(
|
||||
f"({pid}, '{sched_date}', 'Y', NOW())"
|
||||
)
|
||||
|
||||
# required stations — location_id 1–8 per station so unique key (preregister_id, location_id) holds
|
||||
for loc_id, (sid, sname, _) in enumerate(STATIONS, start=1):
|
||||
req_station_rows.append(
|
||||
f"({MCU_ID}, {pid}, {oid}, {loc_id}, {sid}, {q(sname)})"
|
||||
)
|
||||
|
||||
# ─ checkinout & station progress ─────────────────────────────────────────
|
||||
is_two_day = (i % 15 == 0) and (sched_date <= date(2026, 4, 28))
|
||||
cin_h = 7 + (i % 3)
|
||||
cin_m = (i * 7) % 60
|
||||
|
||||
def add_checkin(d, h, m, out_offset_min):
|
||||
ci = dt(d, h, m)
|
||||
co = dt(d, h, m, out_offset_min) if out_offset_min else 'NULL'
|
||||
co_val = q(co) if out_offset_min else 'NULL'
|
||||
checkin_rows.append(
|
||||
f"({MCU_ID}, {pid}, {oid}, '{d}', '{h:02d}:{m:02d}:00', {co_val}, 'Y', NOW())"
|
||||
)
|
||||
|
||||
def add_station(d, sid, sname, src, sampling_off, recv_off, proc_off, done_off):
|
||||
"""All offsets in minutes from d h:m; None = NULL."""
|
||||
base_h, base_m = cin_h, cin_m
|
||||
def ts(off):
|
||||
if off is None: return 'NULL'
|
||||
return q(dt(d, base_h, base_m, off))
|
||||
if src == 'lab':
|
||||
station_rows.append(
|
||||
f"({oid}, {pid}, {MCU_ID}, {sid}, {q(sname)}, {q(src)}, '{d}', "
|
||||
f"{ts(sampling_off)}, {ts(recv_off)}, {ts(proc_off)}, {ts(done_off)}, NOW())"
|
||||
)
|
||||
else:
|
||||
station_rows.append(
|
||||
f"({oid}, {pid}, {MCU_ID}, {sid}, {q(sname)}, {q(src)}, '{d}', "
|
||||
f"NULL, NULL, {ts(proc_off)}, {ts(done_off)}, NOW())"
|
||||
)
|
||||
|
||||
# ─ CASE 1: fully done (April 20–28) ──────────────────────────────────────
|
||||
if sched_date < date(2026, 4, 29):
|
||||
if is_two_day:
|
||||
# day 1: 4 stations, day 2: remaining 4
|
||||
add_checkin(sched_date, cin_h, cin_m, 200)
|
||||
# day 1 stations
|
||||
add_station(sched_date, 1, 'Sample Station Phlebotomy', 'lab', 30, 45, 90, 120)
|
||||
add_station(sched_date, 31, 'Sample Station Urine', 'lab', 20, 35, 75, 95)
|
||||
add_station(sched_date, 17, 'Sample Station TB/BB', 'nonlab', None,None, 15, 25)
|
||||
add_station(sched_date, 4, 'Sample Station Treadmill', 'nonlab', None,None, 45, 65)
|
||||
# day 2
|
||||
d2 = sched_date + timedelta(days=1)
|
||||
add_checkin(d2, 8, 0, 180)
|
||||
# for day2 stations, use 8:00 as base — override cin_h/cin_m temporarily
|
||||
orig_h, orig_m = cin_h, cin_m
|
||||
cin_h, cin_m = 8, 0
|
||||
add_station(d2, 5, 'Sample Station ECG', 'nonlab', None,None, 20, 35)
|
||||
add_station(d2, 2, 'Sample Station Rontgen', 'nonlab', None,None, 50, 70)
|
||||
add_station(d2, 7, 'Sample Station Pemeriksaan Fisik', 'nonlab', None,None, 80, 100)
|
||||
add_station(d2, 33, 'Sample Station Visus dan Buta Warna','nonlab', None,None,115, 135)
|
||||
cin_h, cin_m = orig_h, orig_m
|
||||
else:
|
||||
add_checkin(sched_date, cin_h, cin_m, 240 + (i % 60))
|
||||
add_station(sched_date, 1, 'Sample Station Phlebotomy', 'lab', 30, 45, 90, 120)
|
||||
add_station(sched_date, 31, 'Sample Station Urine', 'lab', 20, 35, 75, 95)
|
||||
add_station(sched_date, 17, 'Sample Station TB/BB', 'nonlab', None,None, 15, 25)
|
||||
add_station(sched_date, 4, 'Sample Station Treadmill', 'nonlab', None,None, 45, 65)
|
||||
add_station(sched_date, 5, 'Sample Station ECG', 'nonlab', None,None, 75, 95)
|
||||
add_station(sched_date, 2, 'Sample Station Rontgen', 'nonlab', None,None,110, 130)
|
||||
add_station(sched_date, 7, 'Sample Station Pemeriksaan Fisik', 'nonlab', None,None,145, 165)
|
||||
add_station(sched_date, 33, 'Sample Station Visus dan Buta Warna','nonlab',None,None,175, 200)
|
||||
|
||||
# ─ CASE 2: April 29 — checked in, most stations done ────────────────────
|
||||
elif sched_date == date(2026, 4, 29):
|
||||
add_checkin(sched_date, cin_h, cin_m, None) # no checkout yet
|
||||
add_station(sched_date, 17, 'Sample Station TB/BB', 'nonlab', None,None, 15, 25)
|
||||
add_station(sched_date, 4, 'Sample Station Treadmill', 'nonlab', None,None, 45, 65)
|
||||
add_station(sched_date, 5, 'Sample Station ECG', 'nonlab', None,None, 80, 95)
|
||||
add_station(sched_date, 2, 'Sample Station Rontgen', 'nonlab', None,None, 110, 130)
|
||||
add_station(sched_date, 7, 'Sample Station Pemeriksaan Fisik', 'nonlab', None,None, 145, 165)
|
||||
add_station(sched_date, 33, 'Sample Station Visus dan Buta Warna','nonlab',None,None, 175, 195)
|
||||
# some patients: lab still processing
|
||||
if i % 4 == 0:
|
||||
# lab done
|
||||
add_station(sched_date, 1, 'Sample Station Phlebotomy', 'lab', 30, 45, 90, 120)
|
||||
add_station(sched_date, 31, 'Sample Station Urine', 'lab', 20, 35, 75, 95)
|
||||
else:
|
||||
# lab in process — no DoneAt
|
||||
add_station(sched_date, 1, 'Sample Station Phlebotomy', 'lab', 30, 45, None, None)
|
||||
add_station(sched_date, 31, 'Sample Station Urine', 'lab', 20, 35, None, None)
|
||||
|
||||
# ─ CASE 3: April 30 — today ──────────────────────────────────────────────
|
||||
elif sched_date == TODAY:
|
||||
local_idx = i - 750 # 1–75 within today
|
||||
if local_idx <= 60:
|
||||
# arrived this morning — partial stations
|
||||
add_checkin(sched_date, cin_h, cin_m, None)
|
||||
add_station(sched_date, 17, 'Sample Station TB/BB', 'nonlab', None,None, 15, 25)
|
||||
add_station(sched_date, 4, 'Sample Station Treadmill', 'nonlab', None,None, 45, 65)
|
||||
if local_idx % 3 == 0:
|
||||
# 20 patients done 3 stations
|
||||
add_station(sched_date, 5, 'Sample Station ECG', 'nonlab', None,None, 80, 95)
|
||||
if local_idx % 5 == 0:
|
||||
# 12 patients done lab too
|
||||
add_station(sched_date, 1, 'Sample Station Phlebotomy', 'lab', 30, 45, None, None)
|
||||
add_station(sched_date, 31, 'Sample Station Urine', 'lab', 20, 35, None, None)
|
||||
# patients 61–75: not yet arrived (no checkin, no stations)
|
||||
|
||||
# ─ resume status & published ─────────────────────────────────────────────
|
||||
# validated: April 20–27 (days 0–7)
|
||||
# published: April 20–25 (days 0–5)
|
||||
if day_idx <= 7:
|
||||
validated = 'Y'
|
||||
status = 'DONE'
|
||||
else:
|
||||
validated = 'N'
|
||||
status = ''
|
||||
|
||||
published = 'Y' if day_idx <= 5 else 'N'
|
||||
|
||||
if sched_date < TODAY:
|
||||
resume_rows.append(
|
||||
f"({pid}, {MCU_ID}, {q(status)}, {q(validated)}, {q(published)}, NOW())"
|
||||
)
|
||||
|
||||
# published_mcu_dashboard_sync — all patients from sched < today get a row
|
||||
if sched_date < TODAY:
|
||||
has_pdf = (i <= PDF_COUNT)
|
||||
file_url = f"'{PDF_BASE}/R2604{i:04d}_resume_individu.pdf'" if has_pdf else 'NULL'
|
||||
pdf_status = 'Y' if has_pdf else 'N'
|
||||
published_rows.append(
|
||||
f"({oid}, {q(pdf_status)}, {file_url}, 'Y', NOW(), 0, NOW(), 0)"
|
||||
)
|
||||
|
||||
# kelainan_details — ~33% of validated patients (days 0–7)
|
||||
if day_idx <= 7 and validated == 'Y' and i % 3 == 0:
|
||||
for (grp, kname, prob) in KELAINAN_GROUPS:
|
||||
# Use deterministic threshold based on i and grp index
|
||||
grp_idx = KELAINAN_GROUPS.index((grp, kname, prob))
|
||||
threshold = int(prob * 100)
|
||||
if (i + grp_idx * 17) % 100 < threshold:
|
||||
fcat_name, fcat_eng, fcat_lvl = FITNESS_CATS[grp]
|
||||
kelainan_rows.append(
|
||||
f"('MCU-DEMO-2026', {oid}, '{sched_date} 08:00:00', {q(lab_no)}, "
|
||||
f"{age}, {pid}, {q(gender)}, {q(nip)}, {q(dept)}, {q(dept)}, "
|
||||
f"{q(name)}, {q(name)}, {q(grp)}, 1, {q(kname)}, {q(kname)}, "
|
||||
f"1, {q(kname)}, 1, {q(grp)}, "
|
||||
f"1, {q(fcat_name)}, {q(fcat_eng)}, {fcat_lvl}, "
|
||||
f"{MCU_ID}, NULL, NULL)"
|
||||
)
|
||||
|
||||
# ── batch insert helpers ───────────────────────────────────────────────────────
|
||||
CHUNK = 200
|
||||
|
||||
def emit_inserts(table, cols, rows, chunk=CHUNK):
|
||||
if not rows:
|
||||
return
|
||||
lines.append(f"-- {table} ({len(rows)} rows)")
|
||||
for start in range(0, len(rows), chunk):
|
||||
batch = rows[start:start+chunk]
|
||||
lines.append(
|
||||
f"INSERT INTO {table} ({cols}) VALUES\n " +
|
||||
",\n ".join(batch) + ";"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
emit_inserts(
|
||||
"mcu_patient",
|
||||
"Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName, Mcu_PatientNIP, "
|
||||
"Mcu_PatientGender, Mcu_PatientDOB, Mcu_PatientAge, Mcu_PatientDepartment, "
|
||||
"Mcu_PatientPosisi, Mcu_PatientOrderID, Mcu_PatientIsRegistered, Mcu_PatientIsActive, "
|
||||
"Mcu_PatientSyncedAt",
|
||||
patient_rows
|
||||
)
|
||||
|
||||
emit_inserts(
|
||||
"mcu_patient_schedule",
|
||||
"Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate, "
|
||||
"Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt",
|
||||
schedule_rows
|
||||
)
|
||||
|
||||
emit_inserts(
|
||||
"mcu_patient_required_station",
|
||||
"mcu_id, preregister_id, order_header_id, location_id, sample_station_id, station_name",
|
||||
req_station_rows
|
||||
)
|
||||
|
||||
emit_inserts(
|
||||
"mcu_checkinout",
|
||||
"Mcu_CheckinoutMcuID, Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID, "
|
||||
"Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime, "
|
||||
"Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt",
|
||||
checkin_rows
|
||||
)
|
||||
|
||||
emit_inserts(
|
||||
"mcu_station_progress",
|
||||
"Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID, "
|
||||
"Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource, "
|
||||
"Mcu_StationProgressCheckinDate, Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, "
|
||||
"Mcu_StationProgressProcessAt, Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt",
|
||||
station_rows
|
||||
)
|
||||
|
||||
emit_inserts(
|
||||
"mcu_patient_resume_status",
|
||||
"Mcu_PatientResumeStatusPreregisterID, Mcu_PatientResumeStatusMcuID, "
|
||||
"Mcu_PatientResumeStatusStatus, Mcu_PatientResumeStatusValidated, "
|
||||
"Mcu_PatientResumeStatusPublished, Mcu_PatientResumeSyncedAt",
|
||||
resume_rows
|
||||
)
|
||||
|
||||
emit_inserts(
|
||||
"published_mcu_dashboard_sync",
|
||||
"Published_McuDasboardT_OrderHeaderID, Published_McuDasboardStatus, "
|
||||
"Published_McuDasboardFileUrl, Published_McuDasboardIsActive, "
|
||||
"Published_McuDasboardCreated, Published_McuDasboardCreatedUserID, "
|
||||
"Published_McuDasboardLastUpdated, Published_McuDasboardLastUpdatedUserID",
|
||||
published_rows
|
||||
)
|
||||
|
||||
emit_inserts(
|
||||
"kelainan_details",
|
||||
"Numbering, T_OrderHeaderID, T_OrderHeaderDate, T_OrderHeaderLabNumber, "
|
||||
"AgePatient, M_PatientID, M_PatientGender, M_PatientNIP, "
|
||||
"M_PatientDepartement, M_PatientDivisi, PatientName, M_PatientName, "
|
||||
"GroupResult, Nat_TestID, Nat_TestCode, Nat_TestName, "
|
||||
"Mcu_KelainanID, Mcu_KelainanName, Mcu_KelainanGroupSummaryID, Mcu_KelainanGroupSummaryName, "
|
||||
"Mcu_FitnessCategoryID, Mcu_FitnessCategoryName, Mcu_FitnessCategoryEng, Mcu_FitnessCategoryLevel, "
|
||||
"Mgm_McuID, Mcu_GenerateID, Mcu_ProjectID",
|
||||
kelainan_rows
|
||||
)
|
||||
|
||||
lines.append("SET FOREIGN_KEY_CHECKS = 1;")
|
||||
lines.append("SELECT 'Demo data seeded successfully.' AS status;")
|
||||
|
||||
print('\n'.join(lines))
|
||||
1
cpone-dashboard/static/css/custom.css
Normal file
1
cpone-dashboard/static/css/custom.css
Normal file
@@ -0,0 +1 @@
|
||||
/* custom styles — keep minimal, prefer tailwind classes */
|
||||
BIN
cpone-dashboard/static/img/logo.png
Normal file
BIN
cpone-dashboard/static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
182
cpone-dashboard/templates/abnormal/index.html
Normal file
182
cpone-dashboard/templates/abnormal/index.html
Normal file
@@ -0,0 +1,182 @@
|
||||
{{define "title"}}Abnormal Monitoring — CpOne{{end}}
|
||||
{{define "header-title"}}Abnormal Monitoring{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$proj := .CurrentProject}}
|
||||
{{$group := .Group}}
|
||||
|
||||
<section class="card p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
|
||||
<h2 class="mt-1 text-lg font-semibold text-slate-900">
|
||||
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-sm text-slate-500">
|
||||
{{$proj.Number}} • {{$proj.CorporateName}} •
|
||||
<span class="num">{{$proj.StartDate | fmtDate}}</span> – <span class="num">{{$proj.EndDate | fmtDate}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
|
||||
Ganti project
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card p-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<a href="{{b "/abnormal"}}"
|
||||
class="rounded-xl px-4 py-2 font-semibold transition
|
||||
{{if eq $group ""}}bg-brand-500 text-white{{else}}border border-brand-500 text-brand-500 hover:bg-brand-50{{end}}">
|
||||
Semua Kelainan
|
||||
</a>
|
||||
{{range .Groups}}
|
||||
<a href="{{b "/abnormal"}}?group={{. | urlquery}}"
|
||||
class="rounded-xl px-4 py-2 font-semibold transition
|
||||
{{if eq . $group}}bg-brand-500 text-white{{else}}border border-brand-500 text-brand-500 hover:bg-brand-50{{end}}">
|
||||
{{.}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<article class="card border-l-4 border-l-brand-400 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Peserta</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Peserta aktif dalam project</p>
|
||||
</article>
|
||||
<article class="card border-l-4 border-l-emerald-400 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Normal</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-emerald-600">{{.Summary.Normal}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Tanpa temuan kelainan</p>
|
||||
</article>
|
||||
<article class="card border-l-4 border-l-red-400 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Abnormal</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-red-500">{{.Summary.Abnormal}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">
|
||||
{{if eq $group ""}}Ada temuan kelainan{{else}}Kelainan: {{$group}}{{end}}
|
||||
</p>
|
||||
</article>
|
||||
<article class="card border-l-4 border-l-amber-400 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Abnormal Rate</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-amber-600">{{.Summary.AbnormalRate}}%</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Persentase dari total peserta</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-5 xl:grid-cols-2">
|
||||
<article class="card p-5">
|
||||
<p class="mb-3 text-sm font-semibold text-slate-700">Normal vs Abnormal</p>
|
||||
<div id="staff-chart" class="h-72 w-full"></div>
|
||||
</article>
|
||||
|
||||
<article class="card p-5">
|
||||
<p class="mb-3 text-sm font-semibold text-slate-700">Distribusi Kelompok Usia</p>
|
||||
<div id="age-chart" class="h-72 w-full"></div>
|
||||
</article>
|
||||
|
||||
<article class="card p-5">
|
||||
<p class="mb-3 text-sm font-semibold text-slate-700">Gender</p>
|
||||
<div id="gender-chart" class="h-72 w-full"></div>
|
||||
</article>
|
||||
|
||||
<article id="dept-wrap" class="card p-5">
|
||||
<p class="mb-3 text-sm font-semibold text-slate-700">Departemen</p>
|
||||
<div id="dept-chart" class="h-72 w-full"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const staffData = {{.StaffJSON}};
|
||||
const ageData = {{.AgeJSON}};
|
||||
const genderData = {{.GenderJSON}};
|
||||
const deptData = {{.DeptJSON}};
|
||||
|
||||
const normalColor = '#3b50a0';
|
||||
const abnormalColor = '#EF4444';
|
||||
|
||||
const staffEl = document.getElementById('staff-chart');
|
||||
const ageEl = document.getElementById('age-chart');
|
||||
const genderEl = document.getElementById('gender-chart');
|
||||
const deptEl = document.getElementById('dept-chart');
|
||||
const deptWrap = document.getElementById('dept-wrap');
|
||||
|
||||
if (staffEl && typeof echarts !== 'undefined') {
|
||||
const staffChart = echarts.init(staffEl);
|
||||
staffChart.setOption({
|
||||
color: [normalColor, abnormalColor],
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { formatter: '{b}: {c}' },
|
||||
data: [
|
||||
{ value: staffData.normal, name: 'Normal' },
|
||||
{ value: staffData.abnormal, name: 'Abnormal' }
|
||||
]
|
||||
}]
|
||||
});
|
||||
window.addEventListener('resize', () => staffChart.resize());
|
||||
}
|
||||
|
||||
if (ageEl && ageData && typeof echarts !== 'undefined') {
|
||||
const ageChart = echarts.init(ageEl);
|
||||
ageChart.setOption({
|
||||
color: [abnormalColor],
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||||
xAxis: { type: 'category', data: ageData.labels },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ name: 'Abnormal', type: 'bar', data: ageData.abnormal, barMaxWidth: 48 }]
|
||||
});
|
||||
window.addEventListener('resize', () => ageChart.resize());
|
||||
}
|
||||
|
||||
if (genderEl && genderData && typeof echarts !== 'undefined') {
|
||||
const genderChart = echarts.init(genderEl);
|
||||
genderChart.setOption({
|
||||
color: [normalColor, abnormalColor],
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { formatter: '{b}: {c}' },
|
||||
data: genderData.labels.map(function(l, i) {
|
||||
return { name: l, value: genderData.abnormal[i] };
|
||||
})
|
||||
}]
|
||||
});
|
||||
window.addEventListener('resize', () => genderChart.resize());
|
||||
}
|
||||
|
||||
if (deptEl && deptData && deptData.labels && deptData.labels.length > 0 && typeof echarts !== 'undefined') {
|
||||
const deptChart = echarts.init(deptEl);
|
||||
deptChart.setOption({
|
||||
color: [normalColor],
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 8, right: 24, top: 8, bottom: 8, containLabel: true },
|
||||
xAxis: { type: 'value' },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: deptData.labels.slice().reverse(),
|
||||
axisLabel: { overflow: 'truncate', width: 160 }
|
||||
},
|
||||
series: [{
|
||||
name: 'Abnormal',
|
||||
type: 'bar',
|
||||
data: deptData.abnormal.slice().reverse(),
|
||||
barMaxWidth: 32,
|
||||
label: { show: true, position: 'right' }
|
||||
}]
|
||||
});
|
||||
window.addEventListener('resize', () => deptChart.resize());
|
||||
} else if (deptWrap) {
|
||||
deptWrap.classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
287
cpone-dashboard/templates/arrival/index.html
Normal file
287
cpone-dashboard/templates/arrival/index.html
Normal file
@@ -0,0 +1,287 @@
|
||||
{{define "title"}}Arrival Tracking — CpOne{{end}}
|
||||
{{define "header-title"}}Arrival Tracking{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$proj := .CurrentProject}}
|
||||
|
||||
<section class="card p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
|
||||
<h2 class="mt-1 text-lg font-semibold text-slate-900">
|
||||
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-sm text-slate-500">
|
||||
{{$proj.Number}} • {{$proj.CorporateName}} •
|
||||
<span class="num">{{$proj.StartDate | fmtDate}}</span> – <span class="num">{{$proj.EndDate | fmtDate}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
|
||||
Ganti project
|
||||
</a>
|
||||
<form method="get" action="{{b "/arrival"}}" class="flex flex-wrap items-center gap-2">
|
||||
<input type="hidden" name="search" value="{{.Search}}"/>
|
||||
<input type="hidden" name="dept" value="{{.Department}}"/>
|
||||
<label class="text-xs font-medium text-slate-500">Tanggal Check-in</label>
|
||||
<select name="date"
|
||||
class="num rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-slate-700 focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-200">
|
||||
{{range .AvailableDates}}
|
||||
<option value="{{.}}" {{if eq . $.Date}}selected{{end}}>{{. | fmtDate}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit" class="rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
|
||||
Lihat
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 sm:grid-cols-3">
|
||||
<article class="card p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Checked In</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.CheckedIn}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Sudah check-in pada tanggal ini</p>
|
||||
</article>
|
||||
<article class="card p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Not Check-in Yet</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Pending}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Belum masuk ke area MCU</p>
|
||||
</article>
|
||||
<article class="card p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Schedule</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Peserta yang dijadwalkan hari ini</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-5 xl:grid-cols-2">
|
||||
<article class="card p-5">
|
||||
<div class="mb-3">
|
||||
<p class="text-sm font-semibold text-slate-700">Check-in Overview</p>
|
||||
<p class="text-xs text-slate-400">Inner ring: checked-in summary, outer ring: distribution by department / posisi</p>
|
||||
</div>
|
||||
<div id="arrival-overview-chart" class="h-72 w-full"></div>
|
||||
</article>
|
||||
<article class="card p-5">
|
||||
<div class="mb-3">
|
||||
<p class="text-sm font-semibold text-slate-700">Per Station Distribution</p>
|
||||
<p class="text-xs text-slate-400">Current observed station loads</p>
|
||||
</div>
|
||||
<div id="station-distribution-chart" class="h-64 w-full"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card p-4">
|
||||
<form method="get" action="{{b "/arrival"}}" class="grid gap-3 md:grid-cols-3">
|
||||
<input type="hidden" name="date" value="{{.Date}}"/>
|
||||
<div class="md:col-span-2">
|
||||
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Participant</label>
|
||||
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Name or Employee ID"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="dept" class="mb-2 block text-sm font-medium text-slate-600">Department</label>
|
||||
<select id="dept" name="dept"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
|
||||
<option value="" {{if eq .Department ""}}selected{{end}}>All Departments</option>
|
||||
{{range .DepartmentOptions}}
|
||||
<option value="{{.}}" {{if eq . $.Department}}selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-3 flex justify-end">
|
||||
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-700">Live Arrival List</h2>
|
||||
<p class="text-xs text-slate-400">Tanggal: {{.Date | fmtDate}}</p>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-slate-100 px-5 py-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 font-medium text-slate-600">Not Performed Yet</span>
|
||||
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 font-medium text-amber-700">In Progress</span>
|
||||
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 font-medium text-emerald-700">Performed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .FilteredRows}}
|
||||
<div class="hidden overflow-x-auto md:block">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Time</th>
|
||||
<th class="px-4 py-3 font-medium">Employee ID</th>
|
||||
<th class="px-4 py-3 font-medium">Name</th>
|
||||
<th class="px-4 py-3 font-medium">Department</th>
|
||||
<th class="px-4 py-3 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{{range .FilteredRows}}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 num">{{if .InTime}}{{.InTime}}{{else}}-{{end}}</td>
|
||||
<td class="px-4 py-3 num">{{.NIP}}</td>
|
||||
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{.Department}}</td>
|
||||
<td class="px-4 py-3">
|
||||
{{if .Stations}}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{{range .Stations}}
|
||||
{{if eq .Tone "success"}}
|
||||
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span>
|
||||
{{else if eq .Tone "warning"}}
|
||||
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span>
|
||||
{{else if eq .Tone "danger"}}
|
||||
<span class="rounded-full border border-rose-200 bg-rose-50 px-2 py-1 text-xs font-medium text-rose-700">{{.Name}}</span>
|
||||
{{else}}
|
||||
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{if eq .StatusTone "success"}}
|
||||
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span>
|
||||
{{else if eq .StatusTone "warning"}}
|
||||
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Status}}</span>
|
||||
{{else}}
|
||||
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Status}}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 p-4 md:hidden">
|
||||
{{range .FilteredRows}}
|
||||
<article class="rounded-xl border border-slate-200 p-3">
|
||||
<p class="font-semibold text-slate-700">{{.Name}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">{{if .InTime}}{{.InTime}}{{else}}-{{end}} • {{.NIP}} • {{.Department}}</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{{if .Stations}}
|
||||
{{range .Stations}}
|
||||
{{if eq .Tone "success"}}
|
||||
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span>
|
||||
{{else if eq .Tone "warning"}}
|
||||
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span>
|
||||
{{else if eq .Tone "danger"}}
|
||||
<span class="rounded-full border border-rose-200 bg-rose-50 px-2 py-1 text-xs font-medium text-rose-700">{{.Name}}</span>
|
||||
{{else}}
|
||||
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{if eq .StatusTone "success"}}
|
||||
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span>
|
||||
{{else if eq .StatusTone "warning"}}
|
||||
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Status}}</span>
|
||||
{{else}}
|
||||
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Status}}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="px-5 py-10 text-center text-sm text-slate-400">
|
||||
Belum ada data arrival pada tanggal ini.
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const overviewData = {{.OverviewJSON}};
|
||||
const stationData = {{.DepartmentJSON}};
|
||||
const deptColors = ['#f59e0b', '#8b5cf6', '#f97316', '#06b6d4', '#ec4899', '#84cc16', '#14b8a6'];
|
||||
|
||||
const overviewEl = document.getElementById('arrival-overview-chart');
|
||||
if (overviewEl && overviewData && typeof echarts !== 'undefined') {
|
||||
const overviewChart = echarts.init(overviewEl);
|
||||
const outerData = (overviewData.depts || []).map(function(d) {
|
||||
return { value: d.value, name: d.name };
|
||||
});
|
||||
overviewChart.setOption({
|
||||
color: ['#3b50a0', '#cbd5e1'].concat(deptColors),
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
orient: 'vertical',
|
||||
right: 0,
|
||||
top: 'middle',
|
||||
textStyle: { color: '#64748b', fontSize: 11 },
|
||||
selectedMode: false,
|
||||
pageIconColor: '#3b50a0',
|
||||
pageTextStyle: { color: '#64748b' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Check-in Summary',
|
||||
type: 'pie',
|
||||
radius: ['28%', '45%'],
|
||||
center: ['38%', '48%'],
|
||||
label: { color: '#334155', formatter: '{b}' },
|
||||
data: [
|
||||
{ value: overviewData.checkedIn || 0, name: 'Checked In' },
|
||||
{ value: overviewData.pending || 0, name: 'Not Check-in Yet' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Dept Detail',
|
||||
type: 'pie',
|
||||
radius: ['55%', '72%'],
|
||||
center: ['38%', '48%'],
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
data: outerData
|
||||
}
|
||||
]
|
||||
});
|
||||
window.addEventListener('resize', () => overviewChart.resize());
|
||||
}
|
||||
|
||||
const stationEl = document.getElementById('station-distribution-chart');
|
||||
if (stationEl && stationData && typeof echarts !== 'undefined') {
|
||||
const stationChart = echarts.init(stationEl);
|
||||
var revLabels = (stationData.labels || []).slice().reverse();
|
||||
var revCounts = (stationData.counts || []).slice().reverse();
|
||||
stationChart.setOption({
|
||||
color: ['#3b50a0'],
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 10, right: 20, top: 10, bottom: 10, containLabel: true },
|
||||
xAxis: { type: 'value', axisLabel: { color: '#64748b' } },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
axisLabel: { color: '#64748b' },
|
||||
data: revLabels
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Patients',
|
||||
type: 'bar',
|
||||
barWidth: 18,
|
||||
data: revCounts,
|
||||
itemStyle: { borderRadius: [0, 6, 6, 0] }
|
||||
}
|
||||
]
|
||||
});
|
||||
window.addEventListener('resize', () => stationChart.resize());
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
105
cpone-dashboard/templates/auth/password.html
Normal file
105
cpone-dashboard/templates/auth/password.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{{define "password"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Ganti Password — CpOne Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: { sans: ['Plus Jakarta Sans', 'sans-serif'] },
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef0fb',
|
||||
100: '#dde2f7',
|
||||
200: '#bbc5ef',
|
||||
300: '#8f9fe4',
|
||||
400: '#6677d6',
|
||||
500: '#3b50a0',
|
||||
600: '#2d3d7a',
|
||||
700: '#212d5a',
|
||||
800: '#161e3c',
|
||||
900: '#0b0f1e',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 font-sans text-slate-800">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="bg-brand-500 text-white">
|
||||
<div class="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
|
||||
<a href="{{b "/projects"}}" class="shrink-0 rounded-lg bg-white px-3 py-1.5">
|
||||
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
|
||||
</a>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold tracking-wide">{{.Username}}</span>
|
||||
<a href="{{b "/logout"}}" class="rounded-lg px-3 py-1.5 font-medium opacity-75 transition hover:bg-white/15 hover:opacity-100">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto w-full max-w-md px-4 py-10 sm:px-6">
|
||||
<div class="mb-6">
|
||||
<a href="{{b "/projects"}}" class="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"/>
|
||||
</svg>
|
||||
Kembali
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm">
|
||||
<h1 class="mb-1 text-lg font-semibold">Ganti Password</h1>
|
||||
<p class="mb-6 text-sm text-slate-500">Masukkan password saat ini untuk verifikasi, lalu isi password baru.</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="mb-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Success}}
|
||||
<div class="mb-5 rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
|
||||
{{.Success}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="{{b "/password"}}" class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<label for="current_password" class="block text-sm font-medium">Password Saat Ini</label>
|
||||
<input id="current_password" name="current_password" type="password" required
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="new_password" class="block text-sm font-medium">Password Baru</label>
|
||||
<input id="new_password" name="new_password" type="password" required minlength="6"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="confirm_password" class="block text-sm font-medium">Konfirmasi Password Baru</label>
|
||||
<input id="confirm_password" name="confirm_password" type="password" required minlength="6"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"/>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full rounded-xl bg-brand-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-600 active:bg-brand-700">
|
||||
Simpan Password Baru
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
306
cpone-dashboard/templates/dashboard/index.html
Normal file
306
cpone-dashboard/templates/dashboard/index.html
Normal file
@@ -0,0 +1,306 @@
|
||||
{{define "title"}}Dashboard — CpOne{{end}}
|
||||
{{define "header-title"}}MCU Live Dashboard{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$proj := .Project}}
|
||||
<style>
|
||||
@keyframes sseFlash {
|
||||
0% { box-shadow: 0 0 0 0 rgba(59, 80, 160, 0.45); background-color: #eef2ff; }
|
||||
100% { box-shadow: 0 0 0 0 rgba(59, 80, 160, 0); background-color: transparent; }
|
||||
}
|
||||
|
||||
.sse-updated {
|
||||
animation: sseFlash 3.8s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- SSE wrapper — satu koneksi, semua section dapat update otomatis -->
|
||||
<div hx-ext="sse" sse-connect="{{b "/dashboard/stream"}}?mode=daily&date={{.DateFrom}}">
|
||||
|
||||
<!-- Project Banner -->
|
||||
<section class="card p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
|
||||
{{if .IsLive}}
|
||||
<span class="flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-600">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-red-500 animate-pulse"></span> LIVE
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<h2 class="mt-1 text-lg font-semibold text-slate-900">{{$proj.Label}}</h2>
|
||||
<p class="mt-0.5 text-sm text-slate-500">
|
||||
{{$proj.Number}} • {{$proj.CorporateName}} •
|
||||
<span class="num">{{$proj.StartDate | fmtDate}}</span> – <span class="num">{{$proj.EndDate | fmtDate}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
|
||||
Ganti project
|
||||
</a>
|
||||
<form method="get" action="{{b "/dashboard"}}" class="flex flex-wrap items-center gap-2" id="dashboard-filter-form">
|
||||
<input type="hidden" name="mode" value="daily"/>
|
||||
<label class="text-xs font-medium text-slate-500">Tanggal Check-in</label>
|
||||
<select name="date"
|
||||
id="dashboard-date"
|
||||
onchange="this.form.submit()"
|
||||
class="num rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-slate-700
|
||||
focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-200">
|
||||
{{if .AvailableDates}}
|
||||
{{range .AvailableDates}}
|
||||
<option value="{{.}}" {{if eq . $.DateFrom}}selected{{end}}>{{. | fmtDate}}</option>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<option value="{{.DateFrom}}" selected>{{.DateFrom | fmtDate}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<button type="submit"
|
||||
class="rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
|
||||
Lihat
|
||||
</button>
|
||||
</form>
|
||||
{{if gt .KPI.InvitedStaff 0}}
|
||||
<div class="rounded-xl bg-brand-50 px-3 py-2 text-center">
|
||||
<p class="text-xs text-slate-500">Invited Staff</p>
|
||||
<p class="num text-sm font-semibold text-brand-600">{{.KPI.InvitedStaff}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- KPI Cards — SSE swap -->
|
||||
<section id="sse-kpi" class="grid gap-4 sm:grid-cols-3"
|
||||
sse-swap="kpi" hx-swap="innerHTML">
|
||||
{{range $i := seq 3}}
|
||||
<div class="card h-28 animate-pulse bg-slate-50"></div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- TAT + TAT Chart -->
|
||||
<section class="grid gap-5 xl:grid-cols-[1fr_2fr]">
|
||||
|
||||
<article class="card border-l-4 border-l-brand-500 p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Avg TAT by Hour</p>
|
||||
<p class="mt-0.5 text-sm font-medium text-slate-600">Check-in → Check-out</p>
|
||||
</div>
|
||||
<span class="num rounded-full bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-600">
|
||||
{{.DateFrom | fmtDate}}
|
||||
</span>
|
||||
</div>
|
||||
{{if gt .TAT.CheckedOut 0}}
|
||||
<p class="num mt-5 text-4xl font-semibold text-slate-900">
|
||||
{{div .TAT.AvgMinutes 60}}<span class="text-xl text-slate-400">h</span>
|
||||
{{mod .TAT.AvgMinutes 60}}<span class="text-xl text-slate-400">m</span>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Average turnaround untuk pasien yang sudah selesai</p>
|
||||
<div class="mt-4 grid grid-cols-2 gap-2">
|
||||
<div class="rounded-xl bg-slate-50 px-2 py-2.5 text-center">
|
||||
<p class="text-xs text-slate-400">Fastest</p>
|
||||
<p class="num mt-1 text-sm font-semibold text-slate-700">
|
||||
{{div .TAT.Fastest 60}}h {{mod .TAT.Fastest 60}}m
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-slate-50 px-2 py-2.5 text-center">
|
||||
<p class="text-xs text-slate-400">Median</p>
|
||||
<p class="num mt-1 text-sm font-semibold text-slate-700">
|
||||
{{div .TAT.Median 60}}h {{mod .TAT.Median 60}}m
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="mt-6 text-sm text-slate-400">Belum ada data checkout pada tanggal ini.</p>
|
||||
{{end}}
|
||||
</article>
|
||||
|
||||
<article class="card p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Average TAT by Hour</h2>
|
||||
<span class="text-xs text-slate-400">Hourly average across selected date(s)</span>
|
||||
</div>
|
||||
<div id="tat-chart" class="h-52 w-full"></div>
|
||||
</article>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Station Status + Arrival List — SSE swap -->
|
||||
<section class="grid gap-5 xl:grid-cols-3">
|
||||
|
||||
<article id="sse-stations" class="card xl:col-span-2 overflow-hidden"
|
||||
sse-swap="stations" hx-swap="innerHTML">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Station Status</h2>
|
||||
<span class="flex items-center gap-1.5 text-xs font-medium text-slate-400 animate-pulse">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-slate-300"></span> Connecting...
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5 text-sm text-slate-400">Memuat data...</div>
|
||||
</article>
|
||||
|
||||
<article id="sse-arrivals" class="card overflow-hidden"
|
||||
sse-swap="arrivals" hx-swap="innerHTML">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Arrival List</h2>
|
||||
<a href="{{b "/arrival"}}" class="text-xs font-medium text-brand-500 hover:text-brand-700">View all</a>
|
||||
</div>
|
||||
<div class="p-5 text-sm text-slate-400">Memuat data...</div>
|
||||
</article>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Trend Chart -->
|
||||
<section>
|
||||
<article class="card p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Arrival to Verification Trend by Hour</h2>
|
||||
<span class="num text-xs text-slate-400">
|
||||
{{.DateFrom | fmtDate}}
|
||||
</span>
|
||||
</div>
|
||||
<div id="trend-chart" class="h-64 w-full"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
</div><!-- end SSE wrapper -->
|
||||
|
||||
<!-- Modal: Semua Pasien -->
|
||||
<dialog id="patients-modal"
|
||||
class="w-full max-w-6xl rounded-2xl border border-slate-200 bg-white shadow-2xl p-0 backdrop:bg-slate-900/50"
|
||||
onclick="if(event.target===this)this.close()">
|
||||
<div class="flex flex-col max-h-[85vh]">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4 flex-shrink-0">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-900">Semua Pasien</h2>
|
||||
<p id="patients-modal-subtitle" class="mt-0.5 text-xs text-slate-400"></p>
|
||||
</div>
|
||||
<button onclick="document.getElementById('patients-modal').close()"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Body -->
|
||||
<div id="patients-modal-body" class="overflow-auto flex-1 min-h-0">
|
||||
<div class="flex items-center justify-center py-16 text-slate-400">
|
||||
<svg class="h-5 w-5 animate-spin mr-2 text-brand-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
Memuat data...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
function fmtDate(s) {
|
||||
if (!s) return '';
|
||||
const [y, m, d] = s.split('-');
|
||||
return d + '/' + m + '/' + y;
|
||||
}
|
||||
|
||||
function openPatientsModal() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const modal = document.getElementById('patients-modal');
|
||||
const body = document.getElementById('patients-modal-body');
|
||||
const subtitle = document.getElementById('patients-modal-subtitle');
|
||||
|
||||
const mode = params.get('mode') || 'daily';
|
||||
const date = params.get('date') || '';
|
||||
const dateEnd = params.get('date_end') || '';
|
||||
|
||||
if (mode === 'daily') {
|
||||
subtitle.textContent = date ? 'Tanggal: ' + fmtDate(date) : 'Hari ini';
|
||||
} else {
|
||||
subtitle.textContent = 'Periode: ' + fmtDate(date) + (dateEnd ? ' s/d ' + fmtDate(dateEnd) : '');
|
||||
}
|
||||
|
||||
body.innerHTML = '<div class="flex items-center justify-center py-16 text-slate-400"><svg class="h-5 w-5 animate-spin mr-2 text-brand-400" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg>Memuat data...</div>';
|
||||
modal.showModal();
|
||||
|
||||
fetch('{{b "/dashboard/patients"}}?' + params.toString())
|
||||
.then(r => r.text())
|
||||
.then(html => { body.innerHTML = html; })
|
||||
.catch(() => { body.innerHTML = '<p class="p-6 text-sm text-red-500">Gagal memuat data.</p>'; });
|
||||
}
|
||||
|
||||
(function() {
|
||||
const sseTargets = new Set(['sse-kpi', 'sse-stations', 'sse-arrivals']);
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function (evt) {
|
||||
const target = evt.detail && evt.detail.target ? evt.detail.target : null;
|
||||
if (!target || !target.id || !sseTargets.has(target.id)) return;
|
||||
|
||||
// Skip first hydration so highlight means "new/update", not initial render.
|
||||
if (!target.dataset.sseHydrated) {
|
||||
target.dataset.sseHydrated = '1';
|
||||
return;
|
||||
}
|
||||
|
||||
target.classList.remove('sse-updated');
|
||||
void target.offsetWidth;
|
||||
target.classList.add('sse-updated');
|
||||
});
|
||||
|
||||
const palette = ['#3b50a0', '#6677d6', '#10b981', '#f59e0b'];
|
||||
const tatData = {{.TATChart}};
|
||||
const trendData = {{.TrendChart}};
|
||||
|
||||
const tatEl = document.getElementById('tat-chart');
|
||||
if (tatEl && tatData.labels && tatData.labels.length) {
|
||||
const tatChart = echarts.init(tatEl);
|
||||
tatChart.setOption({
|
||||
color: palette,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: p => `${p[0].axisValue}<br/>Avg TAT: <b>${Math.round(p[0].data)} mnt</b>`
|
||||
},
|
||||
grid: { left: 50, right: 20, top: 16, bottom: 28 },
|
||||
xAxis: {
|
||||
type: 'category', data: tatData.labels,
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } }, axisTick: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', name: 'Mnt',
|
||||
nameTextStyle: { color: '#94a3b8', fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } }
|
||||
},
|
||||
series: [{
|
||||
name: 'Avg TAT', type: 'bar', barWidth: 24, data: tatData.values,
|
||||
itemStyle: { borderRadius: [6, 6, 0, 0], color: '#3b50a0' }
|
||||
}]
|
||||
});
|
||||
window.addEventListener('resize', () => tatChart.resize());
|
||||
}
|
||||
|
||||
const trendEl = document.getElementById('trend-chart');
|
||||
if (trendEl && trendData.labels && trendData.labels.length) {
|
||||
const trendChart = echarts.init(trendEl);
|
||||
trendChart.setOption({
|
||||
color: palette,
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['Checked In', 'Checked Out'],
|
||||
textStyle: { fontSize: 12, color: '#64748b' }, top: 0
|
||||
},
|
||||
grid: { left: 40, right: 20, top: 36, bottom: 28 },
|
||||
xAxis: {
|
||||
type: 'category', data: trendData.labels,
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } }, axisTick: { show: false }
|
||||
},
|
||||
yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f1f5f9' } } },
|
||||
series: [
|
||||
{ name: 'Checked In', type: 'line', smooth: true, data: trendData.checkedIn, symbolSize: 5 },
|
||||
{ name: 'Checked Out', type: 'line', smooth: true, data: trendData.checkedOut, symbolSize: 5, lineStyle: { type: 'dashed' } }
|
||||
]
|
||||
});
|
||||
window.addEventListener('resize', () => trendChart.resize());
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
37
cpone-dashboard/templates/dashboard/partials/arrivals.html
Normal file
37
cpone-dashboard/templates/dashboard/partials/arrivals.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{{define "arrivals"}}
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Arrival List</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
{{if .IsLive}}
|
||||
<span class="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
|
||||
<span class="live-dot h-1.5 w-1.5 rounded-full bg-emerald-500"></span> Live
|
||||
</span>
|
||||
{{end}}
|
||||
<button onclick="openPatientsModal()"
|
||||
class="text-xs font-medium text-brand-500 hover:text-brand-700 transition-colors">
|
||||
Lihat selengkapnya
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Rows}}
|
||||
<ul class="divide-y divide-slate-50 px-3 py-2">
|
||||
{{range .Rows}}
|
||||
<li class="flex items-center gap-3 rounded-xl px-2 py-2.5 transition-colors hover:bg-slate-50">
|
||||
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-brand-50 text-xs font-bold text-brand-600">
|
||||
{{.Name | initials}}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-800">{{.Name}}</p>
|
||||
<p class="text-xs text-slate-400">
|
||||
<span class="num">{{fmtDateTime .Date .InTime}}</span> • {{.Station | stationShort}}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<div class="px-5 py-8 text-center text-sm text-slate-400">
|
||||
Belum ada arrival pada tanggal ini.
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
43
cpone-dashboard/templates/dashboard/partials/kpi.html
Normal file
43
cpone-dashboard/templates/dashboard/partials/kpi.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{{define "kpi"}}
|
||||
<!-- Total Staff -->
|
||||
<article class="card border-l-4 border-l-brand-300 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Staff</p>
|
||||
<svg class="h-4 w-4 text-brand-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="num mt-3 text-3xl font-semibold text-slate-900">{{.TotalStaff}}</p>
|
||||
{{if gt .InvitedStaff 0}}
|
||||
<p class="mt-1 text-xs font-medium text-emerald-600">{{printf "%.1f%%" (pct .TotalStaff .InvitedStaff)}} dari invited</p>
|
||||
{{else}}
|
||||
<p class="mt-1 text-xs text-slate-400">Yang benar-benar datang</p>
|
||||
{{end}}
|
||||
</article>
|
||||
|
||||
<!-- Checked In -->
|
||||
<article class="card border-l-4 border-l-brand-500 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">In Progress</p>
|
||||
<svg class="h-4 w-4 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="num mt-3 text-3xl font-semibold text-slate-900">{{.CheckedIn}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Masih dalam proses</p>
|
||||
</article>
|
||||
|
||||
<!-- Checked Out -->
|
||||
<article class="card border-l-4 border-l-emerald-500 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Checked Out</p>
|
||||
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="num mt-3 text-3xl font-semibold text-slate-900">{{.CheckedOut}}</p>
|
||||
{{if gt .CheckedIn 0}}
|
||||
<p class="mt-1 text-xs text-slate-400">{{printf "%.1f%%" (pct .CheckedOut .CheckedIn)}} selesai</p>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
80
cpone-dashboard/templates/dashboard/partials/patients.html
Normal file
80
cpone-dashboard/templates/dashboard/partials/patients.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{{define "patients"}}
|
||||
{{if not .Patients}}
|
||||
<div class="flex flex-col items-center justify-center py-16 text-slate-400">
|
||||
<svg class="mb-3 h-10 w-10 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
<p class="text-sm font-medium">Belum ada data pasien pada tanggal ini.</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="divide-y divide-slate-100">
|
||||
{{range .Patients}}
|
||||
{{if and (gt .DoneCount 0) (eq .DoneCount (len .Stations))}}
|
||||
<div class="px-5 py-4 bg-emerald-50/60 transition-colors hover:bg-emerald-50">
|
||||
{{else}}
|
||||
<div class="px-5 py-4 hover:bg-slate-50 transition-colors">
|
||||
{{end}}
|
||||
<!-- Row atas: nama + waktu -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-brand-50 text-xs font-bold text-brand-600">
|
||||
{{.Name | initials}}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-slate-800">{{.Name}}</p>
|
||||
{{if $.IsRange}}<p class="text-xs text-slate-400 num">{{.Date | fmtDate}}</p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="num text-slate-500">
|
||||
Masuk: <span class="font-semibold text-slate-700">{{.InTime}}</span>
|
||||
</span>
|
||||
{{if .HasOut}}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 font-semibold text-emerald-700 num">
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
Keluar: {{.OutTime}}
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 font-semibold text-amber-600">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse"></span>
|
||||
Dalam proses
|
||||
</span>
|
||||
{{end}}
|
||||
<span class="text-slate-400">
|
||||
<span class="font-semibold text-brand-600">{{.DoneCount}}</span>/{{len .Stations}} station selesai
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row bawah: station badges -->
|
||||
{{if .Stations}}
|
||||
<div class="mt-2.5 flex flex-wrap gap-1.5 pl-10">
|
||||
{{range .Stations}}
|
||||
{{if .Done}}
|
||||
<span class="inline-flex max-w-full flex-col gap-0.5 rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-800 cursor-default">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
{{.Station}}
|
||||
</span>
|
||||
<span class="num pl-4 text-[10px] font-semibold text-emerald-700/80">
|
||||
Proses: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Selesai: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}}
|
||||
</span>
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="inline-flex max-w-full flex-col gap-0.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500 cursor-default">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="h-3 w-3 flex-shrink-0 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{{.Station}}
|
||||
</span>
|
||||
<span class="num pl-4 text-[10px] font-semibold text-slate-400">
|
||||
Proses: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Selesai: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}}
|
||||
</span>
|
||||
</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
53
cpone-dashboard/templates/dashboard/partials/stations.html
Normal file
53
cpone-dashboard/templates/dashboard/partials/stations.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{{define "stations"}}
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Station Status</h2>
|
||||
{{if .IsLive}}
|
||||
<span class="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
|
||||
<span class="live-dot h-1.5 w-1.5 rounded-full bg-emerald-500"></span> Live
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Rows}}
|
||||
<div class="p-3">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
<th class="px-3 py-2">Station</th>
|
||||
<th class="px-3 py-2 text-right">Sudah</th>
|
||||
<th class="px-3 py-2 text-right">Belum</th>
|
||||
<th class="px-3 py-2 text-right">Total</th>
|
||||
<th class="px-3 py-2 w-40">Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
{{range .Rows}}
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="px-3 py-2.5 font-medium text-slate-700">
|
||||
{{.Station | stationShort}}
|
||||
</td>
|
||||
<td class="num px-3 py-2.5 text-right font-semibold text-slate-900">{{.Processed}}</td>
|
||||
<td class="num px-3 py-2.5 text-right text-amber-600">{{.Pending}}</td>
|
||||
<td class="num px-3 py-2.5 text-right text-slate-400">{{.Total}}</td>
|
||||
<td class="px-3 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full transition-all"
|
||||
style="width: {{printf "%.1f" .Pct}}%; background: {{if ge .Pct 90.0}}#10b981{{else if ge .Pct 60.0}}#3b50a0{{else}}#f59e0b{{end}}">
|
||||
</div>
|
||||
</div>
|
||||
<span class="num text-xs font-semibold {{if ge .Pct 90.0}}text-emerald-600{{else if ge .Pct 60.0}}text-brand-600{{else}}text-amber-600{{end}}">
|
||||
{{printf "%.0f%%" .Pct}}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="px-5 py-8 text-center text-sm text-slate-400">
|
||||
Belum ada data station pada tanggal ini.
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
90
cpone-dashboard/templates/layout/base.html
Normal file
90
cpone-dashboard/templates/layout/base.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{{block "title" .}}CpOne Dashboard{{end}}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/sse.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
mono: ['IBM Plex Mono', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef0fb',
|
||||
100: '#dde2f7',
|
||||
200: '#bbc5ef',
|
||||
300: '#8f9fe4',
|
||||
400: '#6677d6',
|
||||
500: '#3b50a0',
|
||||
600: '#2d3d7a',
|
||||
700: '#212d5a',
|
||||
800: '#161e3c',
|
||||
900: '#0b0f1e',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Plus Jakarta Sans', sans-serif; }
|
||||
.num { font-family: 'IBM Plex Mono', monospace; }
|
||||
.live-dot { animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.card { @apply rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{b "/static/css/custom.css"}}"/>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-slate-800">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="bg-brand-500 text-white">
|
||||
<div class="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="{{b "/dashboard"}}" class="shrink-0 rounded-lg bg-white px-3 py-1.5">
|
||||
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
|
||||
</a>
|
||||
<div>
|
||||
<p class="text-sm font-semibold leading-tight">{{block "header-title" .}}Dashboard{{end}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden items-center gap-1 text-sm sm:flex">
|
||||
<nav class="flex items-center gap-1">
|
||||
<a href="{{b "/dashboard"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Dashboard</a>
|
||||
<a href="{{b "/arrival"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Arrival</a>
|
||||
<a href="{{b "/progress"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Progress</a>
|
||||
<a href="{{b "/abnormal"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Abnormal</a>
|
||||
<a href="{{b "/result"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Result</a>
|
||||
</nav>
|
||||
{{if .Username}}
|
||||
<div class="ml-3 flex items-center gap-2 border-l border-white/20 pl-3">
|
||||
<a href="{{b "/password"}}" class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold tracking-wide transition hover:bg-white/25">{{.Username}}</a>
|
||||
<a href="{{b "/logout"}}" class="rounded-lg px-3 py-1.5 font-medium opacity-75 transition hover:bg-white/15 hover:opacity-100">Logout</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto w-full max-w-7xl space-y-5 px-4 py-5 sm:px-6 lg:px-8">
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
129
cpone-dashboard/templates/login/index.html
Normal file
129
cpone-dashboard/templates/login/index.html
Normal file
@@ -0,0 +1,129 @@
|
||||
{{define "login"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Login — CpOne Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: { sans: ['Plus Jakarta Sans', 'sans-serif'] },
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef0fb',
|
||||
100: '#dde2f7',
|
||||
200: '#bbc5ef',
|
||||
300: '#8f9fe4',
|
||||
400: '#6677d6',
|
||||
500: '#3b50a0',
|
||||
600: '#2d3d7a',
|
||||
700: '#212d5a',
|
||||
800: '#161e3c',
|
||||
900: '#0b0f1e',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 font-sans text-slate-800">
|
||||
<main class="grid min-h-screen lg:grid-cols-2">
|
||||
|
||||
<!-- Left panel -->
|
||||
<section class="relative hidden overflow-hidden lg:flex lg:flex-col lg:justify-between lg:p-12">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500"></div>
|
||||
<div class="absolute -left-24 -top-16 h-80 w-80 rounded-full bg-white/5 blur-3xl"></div>
|
||||
<div class="absolute -bottom-20 right-0 h-96 w-96 rounded-full bg-white/5 blur-3xl"></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<div class="inline-flex rounded-lg bg-white px-4 py-2">
|
||||
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 space-y-4 text-white">
|
||||
<p class="inline-flex rounded-full border border-white/25 bg-white/10 px-4 py-1.5 text-sm backdrop-blur">
|
||||
Corporate MCU Platform
|
||||
</p>
|
||||
<h1 class="max-w-md text-3xl font-semibold leading-snug">
|
||||
Monitor Arrival, Sampling, dan Lab Verification dalam Satu Tempat
|
||||
</h1>
|
||||
<p class="max-w-sm text-sm text-brand-200">
|
||||
Real-time dashboard untuk visibilitas operasional MCU perusahaan.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right panel -->
|
||||
<section class="flex items-center justify-center p-6 sm:p-10">
|
||||
<div class="w-full max-w-md">
|
||||
|
||||
<!-- Logo mobile -->
|
||||
<div class="mb-8 flex justify-center lg:hidden">
|
||||
<div class="rounded-xl bg-brand-500 px-5 py-3">
|
||||
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-10 w-auto brightness-0 invert">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm sm:p-8">
|
||||
<div class="mb-7 space-y-1">
|
||||
<h2 class="text-xl font-semibold">Masuk ke akun Anda</h2>
|
||||
<p class="text-sm text-slate-500">Gunakan username dan password yang terdaftar.</p>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="mb-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="{{b "/mcu-login"}}" class="space-y-5">
|
||||
<div class="space-y-1.5">
|
||||
<label for="username" class="block text-sm font-medium">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
placeholder="Masukkan username"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="password" class="block text-sm font-medium">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder="Masukkan password"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-xl bg-brand-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-600 active:bg-brand-700"
|
||||
>
|
||||
Masuk
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="mt-5 text-center text-xs text-slate-400">CpOne Dashboard — Laboratorium & Klinik Westerindo</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
196
cpone-dashboard/templates/progress/index.html
Normal file
196
cpone-dashboard/templates/progress/index.html
Normal file
@@ -0,0 +1,196 @@
|
||||
{{define "title"}}Result Progress — CpOne{{end}}
|
||||
{{define "header-title"}}Result Progress{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$proj := .CurrentProject}}
|
||||
|
||||
<section class="card p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
|
||||
<h2 class="mt-1 text-lg font-semibold text-slate-900">
|
||||
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-sm text-slate-500">
|
||||
{{$proj.Number}} • {{$proj.CorporateName}} •
|
||||
<span class="num">{{$proj.StartDate | fmtDate}}</span> – <span class="num">{{$proj.EndDate | fmtDate}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
|
||||
Ganti project
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 sm:grid-cols-3">
|
||||
<article class="card p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p>
|
||||
</article>
|
||||
<article class="card p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Validated</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-emerald-600">{{.Summary.Validated}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Resume sudah divalidasi dokter</p>
|
||||
</article>
|
||||
<article class="card p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Published</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-brand-500">{{.Summary.Published}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Hasil sudah dikirim ke peserta</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card p-5">
|
||||
<h2 class="mb-4 text-sm font-semibold text-slate-700">Resume Progress</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-slate-700">Validated</span>
|
||||
<span class="text-xs text-slate-400">
|
||||
{{.Summary.Validated}} / {{.Summary.Total}}
|
||||
<span class="ml-1 font-semibold text-emerald-600">{{.ValidatedPct}}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-2 rounded-full bg-emerald-500 transition-all" style="width: {{.ValidatedPct}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-slate-700">Published</span>
|
||||
<span class="text-xs text-slate-400">
|
||||
{{.Summary.Published}} / {{.Summary.Total}}
|
||||
<span class="ml-1 font-semibold text-brand-500">{{.PublishedPct}}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-2 rounded-full bg-brand-500 transition-all" style="width: {{.PublishedPct}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card p-4">
|
||||
<form method="get" action="{{b "/progress"}}" class="grid gap-3 md:grid-cols-3">
|
||||
<div class="md:col-span-2">
|
||||
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label>
|
||||
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="mb-2 block text-sm font-medium text-slate-600">Status</label>
|
||||
<select id="status" name="status"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
|
||||
<option value="" {{if eq .Status ""}}selected{{end}}>All</option>
|
||||
<option value="validated" {{if eq .Status "validated"}}selected{{end}}>Validated</option>
|
||||
<option value="published" {{if eq .Status "published"}}selected{{end}}>Published</option>
|
||||
<option value="not_validated" {{if eq .Status "not_validated"}}selected{{end}}>Not Validated</option>
|
||||
<option value="not_published" {{if eq .Status "not_published"}}selected{{end}}>Not Published</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-3 flex justify-end">
|
||||
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-700">Patient Resume List</h2>
|
||||
<p class="text-xs text-slate-400">Data dari mcu_patient_resume_status</p>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-slate-100 px-5 py-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 font-medium text-emerald-700">Validated</span>
|
||||
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 font-medium text-slate-600">Not Validated</span>
|
||||
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 font-medium text-brand-500">Published</span>
|
||||
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 font-medium text-slate-600">Not Published</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .FilteredRows}}
|
||||
<div class="hidden overflow-x-auto md:block">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Name</th>
|
||||
<th class="px-4 py-3 font-medium">NIP</th>
|
||||
<th class="px-4 py-3 font-medium">Posisi</th>
|
||||
<th class="px-4 py-3 font-medium">Resume Status</th>
|
||||
<th class="px-4 py-3 font-medium">Validated</th>
|
||||
<th class="px-4 py-3 font-medium">Published</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{{range .FilteredRows}}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
|
||||
<td class="px-4 py-3 num text-slate-500">{{.NIP}}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
|
||||
<td class="px-4 py-3">
|
||||
{{if .ResumeStatus}}
|
||||
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
|
||||
{{else}}
|
||||
<span class="text-xs text-slate-400">—</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{if eq .Validated "Y"}}
|
||||
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Y</span>
|
||||
{{else}}
|
||||
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">N</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{if eq .Published "Y"}}
|
||||
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Y</span>
|
||||
{{else}}
|
||||
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">N</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 p-4 md:hidden">
|
||||
{{range .FilteredRows}}
|
||||
<article class="rounded-xl border border-slate-200 p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-700">{{.Name}}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} • {{.Posisi}}</p>
|
||||
</div>
|
||||
{{if .ResumeStatus}}
|
||||
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="mt-2 flex gap-2">
|
||||
{{if eq .Validated "Y"}}
|
||||
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Validated</span>
|
||||
{{else}}
|
||||
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">Not Validated</span>
|
||||
{{end}}
|
||||
{{if eq .Published "Y"}}
|
||||
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Published</span>
|
||||
{{else}}
|
||||
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">Not Published</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="px-5 py-10 text-center text-sm text-slate-400">
|
||||
Belum ada data resume untuk project ini.
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
119
cpone-dashboard/templates/projects/index.html
Normal file
119
cpone-dashboard/templates/projects/index.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{{define "projects"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Pilih Project — CpOne Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: { sans: ['Plus Jakarta Sans', 'sans-serif'] },
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef0fb',
|
||||
100: '#dde2f7',
|
||||
200: '#bbc5ef',
|
||||
300: '#8f9fe4',
|
||||
400: '#6677d6',
|
||||
500: '#3b50a0',
|
||||
600: '#2d3d7a',
|
||||
700: '#212d5a',
|
||||
800: '#161e3c',
|
||||
900: '#0b0f1e',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 font-sans text-slate-800">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="bg-brand-500 text-white">
|
||||
<div class="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
|
||||
<a href="{{b "/projects"}}" class="shrink-0 rounded-lg bg-white px-3 py-1.5">
|
||||
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
|
||||
</a>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold tracking-wide">{{.Username}}</span>
|
||||
<a href="{{b "/logout"}}" class="rounded-lg px-3 py-1.5 font-medium opacity-75 transition hover:bg-white/15 hover:opacity-100">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-semibold text-slate-800">Pilih Project</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Pilih salah satu project MCU untuk mulai monitoring.</p>
|
||||
</div>
|
||||
|
||||
{{if .CurrentProject}}
|
||||
<div class="mb-6 rounded-2xl border border-brand-100 bg-brand-50 px-4 py-3 text-sm text-brand-700">
|
||||
Project aktif saat ini:
|
||||
<span class="font-semibold">{{if .CurrentProject.Label}}{{.CurrentProject.Label}}{{else}}MCU #{{.CurrentProject.McuID}}{{end}}</span>
|
||||
<span class="text-brand-500">•</span>
|
||||
<a href="{{b "/dashboard"}}" class="font-semibold underline decoration-brand-200 underline-offset-2">Buka dashboard</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if not .Projects}}
|
||||
<div class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-300 bg-white py-16 text-center">
|
||||
<svg class="mb-3 h-10 w-10 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"/>
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-slate-500">Belum ada project yang di-assign</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Hubungi administrator untuk mendapatkan akses.</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{{range .Projects}}
|
||||
<a href="{{b "/projects/select"}}?mcu_id={{.McuID}}"
|
||||
class="group flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-brand-400 hover:shadow-md">
|
||||
|
||||
<!-- Company + badge -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-brand-400">{{.Number}}</p>
|
||||
<p class="mt-0.5 font-semibold leading-snug text-slate-800">{{.CorporateName}}</p>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full bg-brand-50 px-2.5 py-0.5 text-xs font-semibold text-brand-600">
|
||||
MCU #{{.McuID}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
{{if .Label}}
|
||||
<p class="mt-2 text-sm text-slate-500">{{.Label}}</p>
|
||||
{{end}}
|
||||
|
||||
<!-- Footer info -->
|
||||
<div class="mt-4 flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-slate-400">
|
||||
<span>{{.StartDate}}{{if .EndDate}} – {{.EndDate}}{{end}}</span>
|
||||
<span class="font-medium text-slate-500">{{.TotalParticipant}} peserta</span>
|
||||
</div>
|
||||
|
||||
<!-- Hover cta -->
|
||||
<div class="mt-3 flex items-center gap-1 text-xs font-semibold text-brand-500 opacity-0 transition group-hover:opacity-100">
|
||||
Buka Dashboard
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
185
cpone-dashboard/templates/result/index.html
Normal file
185
cpone-dashboard/templates/result/index.html
Normal file
@@ -0,0 +1,185 @@
|
||||
{{define "title"}}Result Reports — CpOne{{end}}
|
||||
{{define "header-title"}}Consolidated Result Reports{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$proj := .CurrentProject}}
|
||||
|
||||
{{/* Section 1: Current project */}}
|
||||
<section class="card p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
|
||||
<h2 class="mt-1 text-lg font-semibold text-slate-900">
|
||||
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-sm text-slate-500">
|
||||
{{$proj.Number}} • {{$proj.CorporateName}} •
|
||||
<span class="num">{{$proj.StartDate | fmtDate}}</span> – <span class="num">{{$proj.EndDate | fmtDate}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
|
||||
Ganti project
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{/* Section 2: Summary cards */}}
|
||||
<section class="grid gap-4 sm:grid-cols-2">
|
||||
<article class="card p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p>
|
||||
</article>
|
||||
<article class="card p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Has PDF</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-brand-500">{{.Summary.HasPDF}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Laporan hasil sudah tersedia</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{{/* Section 3: Filter form */}}
|
||||
<section class="card p-4">
|
||||
<form method="get" action="{{b "/result"}}" class="grid gap-3 md:grid-cols-3">
|
||||
<div class="md:col-span-2">
|
||||
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label>
|
||||
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter" class="mb-2 block text-sm font-medium text-slate-600">Status PDF</label>
|
||||
<select id="filter" name="filter"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
|
||||
<option value="" {{if eq .Filter ""}}selected{{end}}>All</option>
|
||||
<option value="has_pdf" {{if eq .Filter "has_pdf"}}selected{{end}}>Has PDF</option>
|
||||
<option value="no_pdf" {{if eq .Filter "no_pdf"}}selected{{end}}>No PDF</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-3 flex justify-end">
|
||||
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{/* Section 4: Patient list */}}
|
||||
<section class="card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-700">Patient Result List</h2>
|
||||
<p class="text-xs text-slate-400">Data dari published_mcu_dashboard_sync</p>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
|
||||
</div>
|
||||
|
||||
{{if .FilteredRows}}
|
||||
<div class="hidden overflow-x-auto md:block">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Employee ID</th>
|
||||
<th class="px-4 py-3 font-medium">Patient</th>
|
||||
<th class="px-4 py-3 font-medium">Department</th>
|
||||
<th class="px-4 py-3 font-medium">Report Date</th>
|
||||
<th class="px-4 py-3 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{{range .FilteredRows}}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="num px-4 py-3 text-slate-500">{{.NIP}}</td>
|
||||
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
|
||||
<td class="num px-4 py-3 text-slate-500">
|
||||
{{if .ReportDate}}{{.ReportDate | fmtDate}}{{else}}<span class="text-slate-300">—</span>{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{if .FileUrl}}
|
||||
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
|
||||
View PDF
|
||||
</button>
|
||||
{{else}}
|
||||
<span class="text-xs text-slate-300">—</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 p-4 md:hidden">
|
||||
{{range .FilteredRows}}
|
||||
<article class="rounded-xl border border-slate-200 p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-700">{{.Name}}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} • {{.Posisi}}</p>
|
||||
{{if .ReportDate}}<p class="mt-0.5 num text-xs text-slate-400">{{.ReportDate | fmtDate}}</p>{{end}}
|
||||
</div>
|
||||
{{if .FileUrl}}
|
||||
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')"
|
||||
class="shrink-0 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
|
||||
View PDF
|
||||
</button>
|
||||
{{else}}
|
||||
<span class="text-xs text-slate-300">No PDF</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="px-5 py-10 text-center text-sm text-slate-400">
|
||||
Belum ada data untuk project ini.
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{/* PDF Modal */}}
|
||||
<div id="pdf-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||
onclick="if(event.target===this)closePDFModal()">
|
||||
<div class="flex h-full w-full max-w-5xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl">
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-slate-200 px-5 py-3">
|
||||
<p id="pdf-modal-title" class="truncate text-sm font-semibold text-slate-700"></p>
|
||||
<div class="ml-4 flex shrink-0 items-center gap-3">
|
||||
<a id="pdf-modal-link" href="#" target="_blank"
|
||||
class="text-xs font-medium text-brand-500 hover:underline">
|
||||
Buka di tab baru ↗
|
||||
</a>
|
||||
<button onclick="closePDFModal()"
|
||||
class="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe id="pdf-modal-frame" src="" class="min-h-0 flex-1 w-full" frameborder="0"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openPDFModal(url, name) {
|
||||
document.getElementById('pdf-modal-frame').src = url;
|
||||
document.getElementById('pdf-modal-title').textContent = name;
|
||||
document.getElementById('pdf-modal-link').href = url;
|
||||
const modal = document.getElementById('pdf-modal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
function closePDFModal() {
|
||||
const modal = document.getElementById('pdf-modal');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
document.getElementById('pdf-modal-frame').src = '';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closePDFModal();
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
583
docs/superpowers/plans/2026-04-30-result-menu.md
Normal file
583
docs/superpowers/plans/2026-04-30-result-menu.md
Normal file
@@ -0,0 +1,583 @@
|
||||
# Result Menu Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implementasi halaman `/result` yang menampilkan daftar peserta MCU beserta tombol View PDF, mengambil data dari `cpone_dashboard.mcu_patient` dan `cpone_dashboard.published_mcu_dashboard_sync`.
|
||||
|
||||
**Architecture:** Handler mengikuti pola `progress` — fetch semua rows, build summary, apply filter di memory, render template. PDF base URL dikonfigurasi via `.env` sebagai `PDF_BASE_URL`, disimpan di package-level var `pdfBaseURL` di package `result`.
|
||||
|
||||
**Tech Stack:** Go 1.21, Chi router, Go HTML templates (embed), Tailwind via CDN, MySQL 8 (`cpone_dashboard`).
|
||||
|
||||
**Working directory untuk semua command:** `/Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Status | Tanggung jawab |
|
||||
|------|--------|----------------|
|
||||
| `config/config.go` | **Modify** | Tambah field `PDFBaseURL string` |
|
||||
| `.env` | **Modify** | Tambah `PDF_BASE_URL=http://devcpone.aplikasi.web.id/dashboard-files/` |
|
||||
| `.env.example` | **Modify** | Tambah `PDF_BASE_URL=http://your-server/dashboard-files/` |
|
||||
| `menu/result/query.go` | **Rewrite** | Types + query + filter/summary helpers |
|
||||
| `menu/result/handler.go` | **Rewrite** | pageData, pdfBaseURL var, Index handler |
|
||||
| `main.go` | **Modify** | Wire `cfg.PDFBaseURL` ke `result.SetPDFBaseURL` |
|
||||
| `templates/result/index.html` | **Rewrite** | Full page template |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Tambah PDFBaseURL ke config
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/config.go`
|
||||
- Modify: `.env`
|
||||
- Modify: `.env.example`
|
||||
|
||||
- [ ] **Step 1: Update config/config.go**
|
||||
|
||||
Tambah field `PDFBaseURL` ke struct dan `Load()`:
|
||||
|
||||
```go
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppPort string
|
||||
DBDSN string
|
||||
AuthSecret string
|
||||
PDFBaseURL string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("no .env file, reading from environment")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
AppPort: getEnv("APP_PORT", "8080"),
|
||||
DBDSN: getEnv("DB_DSN", ""),
|
||||
AuthSecret: getEnv("AUTH_SECRET", "cpone-change-this-secret"),
|
||||
PDFBaseURL: getEnv("PDF_BASE_URL", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tambah ke .env**
|
||||
|
||||
Buka `.env`, tambah baris di bawah `AUTH_SECRET`:
|
||||
|
||||
```
|
||||
PDF_BASE_URL=http://devcpone.aplikasi.web.id/dashboard-files/
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Tambah ke .env.example**
|
||||
|
||||
Buka `.env.example`, tambah baris di bawah `AUTH_SECRET`:
|
||||
|
||||
```
|
||||
PDF_BASE_URL=http://your-server/dashboard-files/
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verifikasi build**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
go build ./...
|
||||
```
|
||||
|
||||
Expected: tidak ada error output.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
git add config/config.go .env .env.example
|
||||
git commit -m "config: add PDF_BASE_URL env var"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Implementasi query.go
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `menu/result/query.go`
|
||||
|
||||
- [ ] **Step 1: Tulis query.go lengkap**
|
||||
|
||||
```go
|
||||
package result
|
||||
|
||||
import (
|
||||
"cpone-dashboard/db"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ResultRow struct {
|
||||
NIP string
|
||||
Name string
|
||||
Posisi string
|
||||
FileUrl string
|
||||
ReportDate string
|
||||
}
|
||||
|
||||
type ResultSummary struct {
|
||||
Total int
|
||||
HasPDF int
|
||||
}
|
||||
|
||||
func GetResultRows(mcuID int) ([]ResultRow, error) {
|
||||
rows, err := db.DB.Query(`
|
||||
SELECT
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '-') AS nip,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '-') AS name,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS posisi,
|
||||
COALESCE(p.Published_McuDasboardFileUrl, '') AS file_url,
|
||||
CASE
|
||||
WHEN p.Published_McuDasboardFileUrl IS NOT NULL
|
||||
AND p.Published_McuDasboardFileUrl != ''
|
||||
THEN COALESCE(CAST(p.Published_McuDasboardLastUpdated AS CHAR), '')
|
||||
ELSE ''
|
||||
END AS report_date
|
||||
FROM mcu_patient mp
|
||||
LEFT JOIN published_mcu_dashboard_sync p
|
||||
ON p.Published_McuDasboardT_OrderHeaderID = mp.Mcu_PatientOrderID
|
||||
WHERE mp.Mcu_PatientMcuID = ?
|
||||
AND mp.Mcu_PatientIsActive = 'Y'
|
||||
ORDER BY
|
||||
(p.Published_McuDasboardFileUrl IS NOT NULL AND p.Published_McuDasboardFileUrl != '') DESC,
|
||||
mp.Mcu_PatientName ASC
|
||||
`, mcuID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ResultRow
|
||||
for rows.Next() {
|
||||
var r ResultRow
|
||||
if err := rows.Scan(&r.NIP, &r.Name, &r.Posisi, &r.FileUrl, &r.ReportDate); err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func BuildResultSummary(rows []ResultRow) ResultSummary {
|
||||
s := ResultSummary{Total: len(rows)}
|
||||
for _, r := range rows {
|
||||
if r.FileUrl != "" {
|
||||
s.HasPDF++
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func FilterResultRows(rows []ResultRow, search, filter string) []ResultRow {
|
||||
search = strings.ToLower(strings.TrimSpace(search))
|
||||
filter = strings.TrimSpace(filter)
|
||||
if search == "" && filter == "" {
|
||||
return rows
|
||||
}
|
||||
out := make([]ResultRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
switch filter {
|
||||
case "has_pdf":
|
||||
if r.FileUrl == "" {
|
||||
continue
|
||||
}
|
||||
case "no_pdf":
|
||||
if r.FileUrl != "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if search != "" {
|
||||
hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi)
|
||||
if !strings.Contains(hay, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verifikasi build**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
go build ./...
|
||||
```
|
||||
|
||||
Expected: tidak ada error.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
git add menu/result/query.go
|
||||
git commit -m "result: implement query, summary, and filter helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Implementasi handler.go
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `menu/result/handler.go`
|
||||
|
||||
- [ ] **Step 1: Tulis handler.go lengkap**
|
||||
|
||||
```go
|
||||
package result
|
||||
|
||||
import (
|
||||
"cpone-dashboard/menu/auth"
|
||||
"cpone-dashboard/menu/projects"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var tmpl *template.Template
|
||||
var pdfBaseURL string
|
||||
|
||||
func SetTemplates(t *template.Template) { tmpl = t }
|
||||
func SetPDFBaseURL(u string) { pdfBaseURL = u }
|
||||
|
||||
type pageData struct {
|
||||
Username string
|
||||
CurrentProject projects.ProjectItem
|
||||
Search string
|
||||
Filter string
|
||||
Rows []ResultRow
|
||||
FilteredRows []ResultRow
|
||||
Summary ResultSummary
|
||||
PDFBaseURL string
|
||||
}
|
||||
|
||||
func Index(w http.ResponseWriter, r *http.Request) {
|
||||
username := auth.Username(r)
|
||||
mcuID := auth.SelectedProjectID(r)
|
||||
if mcuID == 0 {
|
||||
http.Redirect(w, r, "/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
project, ok, err := projects.GetUserProject(username, mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/projects", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := GetResultRows(mcuID)
|
||||
if err != nil {
|
||||
http.Error(w, "query error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
summary := BuildResultSummary(rows)
|
||||
search := r.URL.Query().Get("search")
|
||||
filter := r.URL.Query().Get("filter")
|
||||
filteredRows := FilterResultRows(rows, search, filter)
|
||||
|
||||
t := tmpl
|
||||
if t == nil {
|
||||
http.Error(w, "template not ready", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "base", pageData{
|
||||
Username: username,
|
||||
CurrentProject: project,
|
||||
Search: search,
|
||||
Filter: filter,
|
||||
Rows: rows,
|
||||
FilteredRows: filteredRows,
|
||||
Summary: summary,
|
||||
PDFBaseURL: pdfBaseURL,
|
||||
}); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verifikasi build**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
go build ./...
|
||||
```
|
||||
|
||||
Expected: tidak ada error.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
git add menu/result/handler.go
|
||||
git commit -m "result: implement Index handler with filter and summary"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire PDFBaseURL di main.go
|
||||
|
||||
**Files:**
|
||||
- Modify: `main.go` — tambah `result.SetPDFBaseURL(cfg.PDFBaseURL)` setelah `result.SetTemplates(...)`
|
||||
|
||||
- [ ] **Step 1: Temukan baris result.SetTemplates di main.go**
|
||||
|
||||
Cari baris (sekitar line 254):
|
||||
```go
|
||||
result.SetTemplates(newPageTmpl("templates/result/index.html"))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tambah SetPDFBaseURL tepat setelahnya**
|
||||
|
||||
```go
|
||||
result.SetTemplates(newPageTmpl("templates/result/index.html"))
|
||||
result.SetPDFBaseURL(cfg.PDFBaseURL)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verifikasi build**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
go build ./...
|
||||
```
|
||||
|
||||
Expected: tidak ada error.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
git add main.go
|
||||
git commit -m "main: wire PDF_BASE_URL into result handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Implementasi template
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `templates/result/index.html`
|
||||
|
||||
- [ ] **Step 1: Tulis template lengkap**
|
||||
|
||||
```html
|
||||
{{define "title"}}Result Reports — CpOne{{end}}
|
||||
{{define "header-title"}}Consolidated Result Reports{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$proj := .CurrentProject}}
|
||||
|
||||
{{/* Section 1: Current project */}}
|
||||
<section class="card p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
|
||||
<h2 class="mt-1 text-lg font-semibold text-slate-900">
|
||||
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-sm text-slate-500">
|
||||
{{$proj.Number}} • {{$proj.CorporateName}} •
|
||||
<span class="num">{{$proj.StartDate | fmtDate}}</span> – <span class="num">{{$proj.EndDate | fmtDate}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="/projects" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
|
||||
Ganti project
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{/* Section 2: Summary cards */}}
|
||||
<section class="grid gap-4 sm:grid-cols-2">
|
||||
<article class="card p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p>
|
||||
</article>
|
||||
<article class="card p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Has PDF</p>
|
||||
<p class="num mt-2 text-3xl font-semibold text-brand-500">{{.Summary.HasPDF}}</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Laporan hasil sudah tersedia</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{{/* Section 3: Filter form */}}
|
||||
<section class="card p-4">
|
||||
<form method="get" action="/result" class="grid gap-3 md:grid-cols-3">
|
||||
<div class="md:col-span-2">
|
||||
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label>
|
||||
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter" class="mb-2 block text-sm font-medium text-slate-600">Status PDF</label>
|
||||
<select id="filter" name="filter"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
|
||||
<option value="" {{if eq .Filter ""}}selected{{end}}>All</option>
|
||||
<option value="has_pdf" {{if eq .Filter "has_pdf"}}selected{{end}}>Has PDF</option>
|
||||
<option value="no_pdf" {{if eq .Filter "no_pdf"}}selected{{end}}>No PDF</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-3 flex justify-end">
|
||||
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{/* Section 4: Patient list */}}
|
||||
<section class="card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-700">Patient Result List</h2>
|
||||
<p class="text-xs text-slate-400">Data dari published_mcu_dashboard_sync</p>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
|
||||
</div>
|
||||
|
||||
{{if .FilteredRows}}
|
||||
<div class="hidden overflow-x-auto md:block">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Employee ID</th>
|
||||
<th class="px-4 py-3 font-medium">Patient</th>
|
||||
<th class="px-4 py-3 font-medium">Department</th>
|
||||
<th class="px-4 py-3 font-medium">Report Date</th>
|
||||
<th class="px-4 py-3 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{{range .FilteredRows}}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="num px-4 py-3 text-slate-500">{{.NIP}}</td>
|
||||
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
|
||||
<td class="num px-4 py-3 text-slate-500">
|
||||
{{if .ReportDate}}{{.ReportDate | fmtDate}}{{else}}<span class="text-slate-300">—</span>{{end}}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{if .FileUrl}}
|
||||
<a href="{{$.PDFBaseURL}}{{.FileUrl}}" target="_blank"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
|
||||
View PDF
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="text-xs text-slate-300">—</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 p-4 md:hidden">
|
||||
{{range .FilteredRows}}
|
||||
<article class="rounded-xl border border-slate-200 p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-700">{{.Name}}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} • {{.Posisi}}</p>
|
||||
{{if .ReportDate}}<p class="mt-0.5 num text-xs text-slate-400">{{.ReportDate | fmtDate}}</p>{{end}}
|
||||
</div>
|
||||
{{if .FileUrl}}
|
||||
<a href="{{$.PDFBaseURL}}{{.FileUrl}}" target="_blank"
|
||||
class="shrink-0 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
|
||||
View PDF
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="text-xs text-slate-300">No PDF</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="px-5 py-10 text-center text-sm text-slate-400">
|
||||
Belum ada data untuk project ini.
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build dan cek tidak ada syntax error template**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
go build ./...
|
||||
```
|
||||
|
||||
Expected: tidak ada error.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
git add templates/result/index.html
|
||||
git commit -m "result: implement full page template"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Manual verification
|
||||
|
||||
- [ ] **Step 1: Pastikan SSH tunnel aktif, lalu jalankan app**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
make start
|
||||
```
|
||||
|
||||
Expected: `server running on :8080`
|
||||
|
||||
- [ ] **Step 2: Buka browser, login, pilih project**
|
||||
|
||||
Navigasi ke `http://localhost:8080/result`. Harus tampil:
|
||||
- Header "Consolidated Result Reports"
|
||||
- Summary cards: Total Patients dan Has PDF (sesuai data di DB)
|
||||
- Table daftar pasien
|
||||
|
||||
- [ ] **Step 3: Verifikasi tombol View PDF**
|
||||
|
||||
Row pertama (NIP sesuai data) harus ada tombol "View PDF" berwarna brand-500. Klik — harus buka PDF di tab baru dengan URL `http://devcpone.aplikasi.web.id/dashboard-files/2024/09/R2409170003_resume_individu.pdf`.
|
||||
|
||||
- [ ] **Step 4: Verifikasi filter**
|
||||
|
||||
Pilih filter "Has PDF" → hanya 1 row tampil. Pilih "No PDF" → semua row selain 1 tampil. Ketik nama di search → filter berjalan.
|
||||
|
||||
- [ ] **Step 5: Deploy ke server**
|
||||
|
||||
```bash
|
||||
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
|
||||
make deploy
|
||||
```
|
||||
|
||||
Expected: `deployed to one@devcpone.aplikasi.web.id:/home/one/project/cpone-dashboard`
|
||||
107
docs/superpowers/specs/2026-04-27-mcu-dashboard-design.md
Normal file
107
docs/superpowers/specs/2026-04-27-mcu-dashboard-design.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# MCU Dashboard (cpone-dashboard) — Design Spec
|
||||
Date: 2026-04-27
|
||||
|
||||
## Overview
|
||||
Dashboard live monitoring MCU (Medical Check-Up) untuk laboratorium klinik CpOne. Menampilkan data real-time dari kegiatan MCU korporat: KPI harian, TAT, status station, arrival tracking, progress pemeriksaan, abnormal monitoring, dan laporan hasil.
|
||||
|
||||
## Data Architecture
|
||||
```
|
||||
cpone (main DB, Server A)
|
||||
↓ inject/ETL (proyek terpisah)
|
||||
cpone_dashboard (Server A)
|
||||
↓ MySQL replication (otomatis)
|
||||
cpone_dashboard (Server B — production)
|
||||
↓ dibaca oleh
|
||||
Go Dashboard App (Server B)
|
||||
```
|
||||
|
||||
Dashboard app **hanya** konek ke `cpone_dashboard` lokal. Zero dependency ke `cpone`.
|
||||
|
||||
## Tech Stack
|
||||
- **Backend**: Go 1.21, framework Chi (router lightweight)
|
||||
- **Frontend**: Go HTML templates (embed ke binary), HTMX via CDN, ECharts via CDN, Tailwind via CDN
|
||||
- **Database**: MySQL 8.0, single connection ke `cpone_dashboard`
|
||||
- **Build**: Cross-compile di Mac (`GOOS=linux GOARCH=amd64`), deploy binary ke server
|
||||
- **Primary color**: `#3b50a0`
|
||||
|
||||
## Pages
|
||||
1. **Login** — autentikasi user
|
||||
2. **Dashboard** — KPI cards, TAT harian, station status table, arrival list, trend chart (HTMX polling tiap 10s)
|
||||
3. **Arrival Tracking** — daftar peserta check-in
|
||||
4. **Observation Progress** — progress per station pemeriksaan
|
||||
5. **Abnormal Monitoring** — hasil pemeriksaan dengan flag abnormal
|
||||
6. **Result Reports** — laporan hasil konsolidasi per peserta
|
||||
|
||||
## Folder Structure
|
||||
```
|
||||
cpone-dashboard/
|
||||
├── main.go
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── .env ← DB DSN, port, dll
|
||||
├── .env.example
|
||||
├── Makefile ← make build, make deploy
|
||||
│
|
||||
├── config/
|
||||
│ └── config.go
|
||||
│
|
||||
├── db/
|
||||
│ └── db.go ← single connection ke cpone_dashboard
|
||||
│
|
||||
├── menu/
|
||||
│ ├── dashboard/
|
||||
│ │ ├── handler.go
|
||||
│ │ ├── query.go
|
||||
│ │ └── route.go
|
||||
│ ├── arrival/
|
||||
│ │ ├── handler.go
|
||||
│ │ ├── query.go
|
||||
│ │ └── route.go
|
||||
│ ├── progress/
|
||||
│ │ ├── handler.go
|
||||
│ │ ├── query.go
|
||||
│ │ └── route.go
|
||||
│ ├── abnormal/
|
||||
│ │ ├── handler.go
|
||||
│ │ ├── query.go
|
||||
│ │ └── route.go
|
||||
│ └── result/
|
||||
│ ├── handler.go
|
||||
│ ├── query.go
|
||||
│ └── route.go
|
||||
│
|
||||
├── templates/
|
||||
│ ├── layout/
|
||||
│ │ └── base.html
|
||||
│ ├── dashboard/
|
||||
│ │ ├── index.html
|
||||
│ │ └── partials/
|
||||
│ │ ├── kpi.html
|
||||
│ │ ├── stations.html
|
||||
│ │ └── arrivals.html
|
||||
│ ├── arrival/
|
||||
│ │ └── index.html
|
||||
│ ├── progress/
|
||||
│ │ └── index.html
|
||||
│ ├── abnormal/
|
||||
│ │ └── index.html
|
||||
│ └── result/
|
||||
│ └── index.html
|
||||
│
|
||||
└── static/
|
||||
└── css/
|
||||
└── custom.css
|
||||
```
|
||||
|
||||
## Deploy Flow
|
||||
```bash
|
||||
make deploy
|
||||
# = GOOS=linux GOARCH=amd64 go build -o cpone-dashboard .
|
||||
# + scp cpone-dashboard one@devcpone.aplikasi.web.id:/home/one/project/cpone-dashboard/
|
||||
# + ssh ... restart process
|
||||
```
|
||||
|
||||
## Out of Scope
|
||||
- Inject/ETL dari `cpone` ke `cpone_dashboard` (proyek terpisah)
|
||||
- MySQL replication setup
|
||||
- Multi-tenancy / multi-server config
|
||||
114
docs/superpowers/specs/2026-04-30-result-menu-design.md
Normal file
114
docs/superpowers/specs/2026-04-30-result-menu-design.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Result Menu — Design Spec
|
||||
Date: 2026-04-30
|
||||
|
||||
## Overview
|
||||
Halaman `/result` menampilkan daftar peserta MCU beserta tombol "View PDF" untuk membuka laporan hasil konsolidasi. Data diambil sepenuhnya dari `cpone_dashboard` (zero dependency ke `cpone`).
|
||||
|
||||
## Data Sources
|
||||
Semua tabel ada di `cpone_dashboard`:
|
||||
- `mcu_patient` — data peserta (NIP, nama, posisi/dept, order ID)
|
||||
- `published_mcu_dashboard_sync` — file URL PDF per peserta
|
||||
|
||||
Join key: `mcu_patient.Mcu_PatientOrderID = published_mcu_dashboard_sync.Published_McuDasboardT_OrderHeaderID`
|
||||
|
||||
## Config / Env
|
||||
Tambah key baru ke `.env`, `.env.example`, dan `config/config.go`:
|
||||
```
|
||||
PDF_BASE_URL=http://devcpone.aplikasi.web.id/dashboard-files/
|
||||
```
|
||||
Field `PDFBaseURL string` ditambah ke struct `Config`. Nilai ini di-passing ke `result` handler saat setup di `main.go`.
|
||||
|
||||
## Backend — `menu/result/`
|
||||
|
||||
### query.go
|
||||
```go
|
||||
type ResultRow struct {
|
||||
NIP string
|
||||
Name string
|
||||
Posisi string
|
||||
FileUrl string // kosong jika belum ada PDF
|
||||
ReportDate string // Published_McuDasboardLastUpdated
|
||||
}
|
||||
|
||||
type ResultSummary struct {
|
||||
Total int
|
||||
HasPDF int
|
||||
}
|
||||
```
|
||||
|
||||
Query:
|
||||
```sql
|
||||
SELECT
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '-') AS nip,
|
||||
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '-') AS name,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
|
||||
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
|
||||
'-'
|
||||
) AS posisi,
|
||||
COALESCE(p.Published_McuDasboardFileUrl, '') AS file_url,
|
||||
CASE
|
||||
WHEN p.Published_McuDasboardFileUrl IS NOT NULL AND p.Published_McuDasboardFileUrl != ''
|
||||
THEN COALESCE(p.Published_McuDasboardLastUpdated, '')
|
||||
ELSE ''
|
||||
END AS report_date
|
||||
FROM mcu_patient mp
|
||||
LEFT JOIN published_mcu_dashboard_sync p
|
||||
ON p.Published_McuDasboardT_OrderHeaderID = mp.Mcu_PatientOrderID
|
||||
WHERE mp.Mcu_PatientMcuID = ?
|
||||
AND mp.Mcu_PatientIsActive = 'Y'
|
||||
ORDER BY
|
||||
(p.Published_McuDasboardFileUrl IS NOT NULL AND p.Published_McuDasboardFileUrl != '') DESC,
|
||||
mp.Mcu_PatientName ASC
|
||||
```
|
||||
|
||||
Helper functions:
|
||||
- `BuildResultSummary(rows []ResultRow) ResultSummary`
|
||||
- `FilterResultRows(rows []ResultRow, search, filter string) []ResultRow`
|
||||
- filter values: `""` (all), `"has_pdf"`, `"no_pdf"`
|
||||
|
||||
### handler.go
|
||||
`pageData` struct:
|
||||
```go
|
||||
type pageData struct {
|
||||
Username string
|
||||
CurrentProject projects.ProjectItem
|
||||
Search string
|
||||
Filter string
|
||||
Rows []ResultRow
|
||||
FilteredRows []ResultRow
|
||||
Summary ResultSummary
|
||||
PDFBaseURL string
|
||||
}
|
||||
```
|
||||
|
||||
Handler `Index` mengikuti pola progress: redirect ke `/projects` jika belum pilih project, fetch rows, build summary, apply filter, render template.
|
||||
|
||||
`PDFBaseURL` di-inject saat `SetTemplates` — tambah fungsi `SetPDFBaseURL(url string)` di package result.
|
||||
|
||||
### route.go
|
||||
Tidak berubah — sudah ada `r.Get("/", Index)`.
|
||||
|
||||
## Template — `templates/result/index.html`
|
||||
|
||||
**Section 1 — Current project card**
|
||||
Sama persis dengan progress/arrival: nama project, nomor, tombol "Ganti project".
|
||||
|
||||
**Section 2 — Summary cards (2 cards)**
|
||||
- Total Patients
|
||||
- Has PDF (count `FileUrl != ""`)
|
||||
|
||||
**Section 3 — Filter form**
|
||||
- Search input (nama atau NIP)
|
||||
- Dropdown: All / Has PDF / No PDF
|
||||
- Tombol Filter
|
||||
|
||||
**Section 4 — Patient list**
|
||||
- Desktop: table dengan kolom NIP, Nama, Posisi/Dept, Report Date, Action
|
||||
- Mobile: card stack
|
||||
- Action: tombol `View PDF` (buka tab baru) jika `FileUrl != ""`, teks `—` jika kosong
|
||||
- PDF full URL: `PDFBaseURL + FileUrl`
|
||||
|
||||
## Referensi Visual
|
||||
`/PLAN/draft-cpone/06-result.html` — warna dan layout mengikuti color scheme brand yang ada (`brand-500`, `slate-*`), bukan warna `diagnos-*` dari draft.
|
||||
Reference in New Issue
Block a user