commit e29e943c27c967e4d306bf6f0542ac0f591ce258 Author: sas.fajri Date: Thu Apr 30 14:27:01 2026 +0700 Initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9f8e423 --- /dev/null +++ b/.claude/settings.local.json @@ -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)" + ] + } +} diff --git a/PLAN/draft-cpone b/PLAN/draft-cpone new file mode 160000 index 0000000..9069dc0 --- /dev/null +++ b/PLAN/draft-cpone @@ -0,0 +1 @@ +Subproject commit 9069dc0613679a29dc15fe90e88c1de235055a6f diff --git a/cpone-dashboard/.env.example b/cpone-dashboard/.env.example new file mode 100644 index 0000000..0afee2b --- /dev/null +++ b/cpone-dashboard/.env.example @@ -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 diff --git a/cpone-dashboard/.gitignore b/cpone-dashboard/.gitignore new file mode 100644 index 0000000..17ddd34 --- /dev/null +++ b/cpone-dashboard/.gitignore @@ -0,0 +1,3 @@ +.env +cpone-dashboard +cpone-dashboard-linux diff --git a/cpone-dashboard/DEPLOY.md b/cpone-dashboard/DEPLOY.md new file mode 100644 index 0000000..1b6f215 --- /dev/null +++ b/cpone-dashboard/DEPLOY.md @@ -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`. diff --git a/cpone-dashboard/Makefile b/cpone-dashboard/Makefile new file mode 100644 index 0000000..74dabb0 --- /dev/null +++ b/cpone-dashboard/Makefile @@ -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" diff --git a/cpone-dashboard/README.md b/cpone-dashboard/README.md new file mode 100644 index 0000000..7bbe4b4 --- /dev/null +++ b/cpone-dashboard/README.md @@ -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 | diff --git a/cpone-dashboard/assets/logo.png b/cpone-dashboard/assets/logo.png new file mode 100644 index 0000000..77b7fd3 Binary files /dev/null and b/cpone-dashboard/assets/logo.png differ diff --git a/cpone-dashboard/config/config.go b/cpone-dashboard/config/config.go new file mode 100644 index 0000000..087fd80 --- /dev/null +++ b/cpone-dashboard/config/config.go @@ -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 +} diff --git a/cpone-dashboard/db/db.go b/cpone-dashboard/db/db.go new file mode 100644 index 0000000..33cbadd --- /dev/null +++ b/cpone-dashboard/db/db.go @@ -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 +} diff --git a/cpone-dashboard/db/migrations/001_init_schema.sql b/cpone-dashboard/db/migrations/001_init_schema.sql new file mode 100644 index 0000000..fc2d16b --- /dev/null +++ b/cpone-dashboard/db/migrations/001_init_schema.sql @@ -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; diff --git a/cpone-dashboard/db/migrations/002_sp_generate_data.sql b/cpone-dashboard/db/migrations/002_sp_generate_data.sql new file mode 100644 index 0000000..c9fd0c7 --- /dev/null +++ b/cpone-dashboard/db/migrations/002_sp_generate_data.sql @@ -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 ; diff --git a/cpone-dashboard/db/migrations/003_sp_add_station_progress.sql b/cpone-dashboard/db/migrations/003_sp_add_station_progress.sql new file mode 100644 index 0000000..599ca31 --- /dev/null +++ b/cpone-dashboard/db/migrations/003_sp_add_station_progress.sql @@ -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 ; diff --git a/cpone-dashboard/db/migrations/004_dashboard_user.sql b/cpone-dashboard/db/migrations/004_dashboard_user.sql new file mode 100644 index 0000000..fca86d6 --- /dev/null +++ b/cpone-dashboard/db/migrations/004_dashboard_user.sql @@ -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'); diff --git a/cpone-dashboard/db/migrations/005_sp_upsert_dashboard_user.sql b/cpone-dashboard/db/migrations/005_sp_upsert_dashboard_user.sql new file mode 100644 index 0000000..3f83f4c --- /dev/null +++ b/cpone-dashboard/db/migrations/005_sp_upsert_dashboard_user.sql @@ -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'); diff --git a/cpone-dashboard/db/migrations/006_dashboard_user_project.sql b/cpone-dashboard/db/migrations/006_dashboard_user_project.sql new file mode 100644 index 0000000..2ec688f --- /dev/null +++ b/cpone-dashboard/db/migrations/006_dashboard_user_project.sql @@ -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 ; diff --git a/cpone-dashboard/db/migrations/007_sp_fix_dashboard_user.sql b/cpone-dashboard/db/migrations/007_sp_fix_dashboard_user.sql new file mode 100644 index 0000000..35c72a4 --- /dev/null +++ b/cpone-dashboard/db/migrations/007_sp_fix_dashboard_user.sql @@ -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 diff --git a/cpone-dashboard/db/migrations/008_sp_fix_checkin_date.sql b/cpone-dashboard/db/migrations/008_sp_fix_checkin_date.sql new file mode 100644 index 0000000..8ea9389 --- /dev/null +++ b/cpone-dashboard/db/migrations/008_sp_fix_checkin_date.sql @@ -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 ; diff --git a/cpone-dashboard/db/migrations/009_checkinout_add_mcu_id.sql b/cpone-dashboard/db/migrations/009_checkinout_add_mcu_id.sql new file mode 100644 index 0000000..4b5186c --- /dev/null +++ b/cpone-dashboard/db/migrations/009_checkinout_add_mcu_id.sql @@ -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; diff --git a/cpone-dashboard/db/migrations/010_patient_add_age.sql b/cpone-dashboard/db/migrations/010_patient_add_age.sql new file mode 100644 index 0000000..444838d --- /dev/null +++ b/cpone-dashboard/db/migrations/010_patient_add_age.sql @@ -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 ; diff --git a/cpone-dashboard/db/migrations/011_patient_resume_status.sql b/cpone-dashboard/db/migrations/011_patient_resume_status.sql new file mode 100644 index 0000000..434436b --- /dev/null +++ b/cpone-dashboard/db/migrations/011_patient_resume_status.sql @@ -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 ; diff --git a/cpone-dashboard/go.mod b/cpone-dashboard/go.mod new file mode 100644 index 0000000..b87356b --- /dev/null +++ b/cpone-dashboard/go.mod @@ -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 diff --git a/cpone-dashboard/go.sum b/cpone-dashboard/go.sum new file mode 100644 index 0000000..29128c8 --- /dev/null +++ b/cpone-dashboard/go.sum @@ -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= diff --git a/cpone-dashboard/main.go b/cpone-dashboard/main.go new file mode 100644 index 0000000..ea66956 --- /dev/null +++ b/cpone-dashboard/main.go @@ -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) + } +} diff --git a/cpone-dashboard/menu/abnormal/handler.go b/cpone-dashboard/menu/abnormal/handler.go new file mode 100644 index 0000000..9b14eda --- /dev/null +++ b/cpone-dashboard/menu/abnormal/handler.go @@ -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) + } +} diff --git a/cpone-dashboard/menu/abnormal/query.go b/cpone-dashboard/menu/abnormal/query.go new file mode 100644 index 0000000..7157250 --- /dev/null +++ b/cpone-dashboard/menu/abnormal/query.go @@ -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() +} diff --git a/cpone-dashboard/menu/abnormal/route.go b/cpone-dashboard/menu/abnormal/route.go new file mode 100644 index 0000000..a9cfba4 --- /dev/null +++ b/cpone-dashboard/menu/abnormal/route.go @@ -0,0 +1,7 @@ +package abnormal + +import "github.com/go-chi/chi/v5" + +func Routes(r chi.Router) { + r.Get("/", Index) +} diff --git a/cpone-dashboard/menu/arrival/handler.go b/cpone-dashboard/menu/arrival/handler.go new file mode 100644 index 0000000..f823cdf --- /dev/null +++ b/cpone-dashboard/menu/arrival/handler.go @@ -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) + } +} diff --git a/cpone-dashboard/menu/arrival/query.go b/cpone-dashboard/menu/arrival/query.go new file mode 100644 index 0000000..afc6347 --- /dev/null +++ b/cpone-dashboard/menu/arrival/query.go @@ -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 +} diff --git a/cpone-dashboard/menu/arrival/route.go b/cpone-dashboard/menu/arrival/route.go new file mode 100644 index 0000000..53fd8a9 --- /dev/null +++ b/cpone-dashboard/menu/arrival/route.go @@ -0,0 +1,7 @@ +package arrival + +import "github.com/go-chi/chi/v5" + +func Routes(r chi.Router) { + r.Get("/", Index) +} diff --git a/cpone-dashboard/menu/auth/handler.go b/cpone-dashboard/menu/auth/handler.go new file mode 100644 index 0000000..4482246 --- /dev/null +++ b/cpone-dashboard/menu/auth/handler.go @@ -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) +} diff --git a/cpone-dashboard/menu/auth/middleware.go b/cpone-dashboard/menu/auth/middleware.go new file mode 100644 index 0000000..b953491 --- /dev/null +++ b/cpone-dashboard/menu/auth/middleware.go @@ -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)) + }) + } +} diff --git a/cpone-dashboard/menu/auth/password.go b/cpone-dashboard/menu/auth/password.go new file mode 100644 index 0000000..54b9b3b --- /dev/null +++ b/cpone-dashboard/menu/auth/password.go @@ -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[:]) +} diff --git a/cpone-dashboard/menu/auth/route.go b/cpone-dashboard/menu/auth/route.go new file mode 100644 index 0000000..e98e4e0 --- /dev/null +++ b/cpone-dashboard/menu/auth/route.go @@ -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) +} diff --git a/cpone-dashboard/menu/auth/session.go b/cpone-dashboard/menu/auth/session.go new file mode 100644 index 0000000..c827f68 --- /dev/null +++ b/cpone-dashboard/menu/auth/session.go @@ -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 +} diff --git a/cpone-dashboard/menu/dashboard/handler.go b/cpone-dashboard/menu/dashboard/handler.go new file mode 100644 index 0000000..c1a3696 --- /dev/null +++ b/cpone-dashboard/menu/dashboard/handler.go @@ -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) +} diff --git a/cpone-dashboard/menu/dashboard/query.go b/cpone-dashboard/menu/dashboard/query.go new file mode 100644 index 0000000..053a8c2 --- /dev/null +++ b/cpone-dashboard/menu/dashboard/query.go @@ -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 +} diff --git a/cpone-dashboard/menu/dashboard/route.go b/cpone-dashboard/menu/dashboard/route.go new file mode 100644 index 0000000..9b08fa1 --- /dev/null +++ b/cpone-dashboard/menu/dashboard/route.go @@ -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) +} diff --git a/cpone-dashboard/menu/dashboard/sse.go b/cpone-dashboard/menu/dashboard/sse.go new file mode 100644 index 0000000..9d59968 --- /dev/null +++ b/cpone-dashboard/menu/dashboard/sse.go @@ -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 + } + } +} diff --git a/cpone-dashboard/menu/progress/handler.go b/cpone-dashboard/menu/progress/handler.go new file mode 100644 index 0000000..7bc5a91 --- /dev/null +++ b/cpone-dashboard/menu/progress/handler.go @@ -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) + } +} diff --git a/cpone-dashboard/menu/progress/query.go b/cpone-dashboard/menu/progress/query.go new file mode 100644 index 0000000..a6e8737 --- /dev/null +++ b/cpone-dashboard/menu/progress/query.go @@ -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 +} diff --git a/cpone-dashboard/menu/progress/route.go b/cpone-dashboard/menu/progress/route.go new file mode 100644 index 0000000..e8eaaf9 --- /dev/null +++ b/cpone-dashboard/menu/progress/route.go @@ -0,0 +1,7 @@ +package progress + +import "github.com/go-chi/chi/v5" + +func Routes(r chi.Router) { + r.Get("/", Index) +} diff --git a/cpone-dashboard/menu/projects/handler.go b/cpone-dashboard/menu/projects/handler.go new file mode 100644 index 0000000..e4899e6 --- /dev/null +++ b/cpone-dashboard/menu/projects/handler.go @@ -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) +} diff --git a/cpone-dashboard/menu/projects/query.go b/cpone-dashboard/menu/projects/query.go new file mode 100644 index 0000000..0de4e79 --- /dev/null +++ b/cpone-dashboard/menu/projects/query.go @@ -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) +} diff --git a/cpone-dashboard/menu/projects/route.go b/cpone-dashboard/menu/projects/route.go new file mode 100644 index 0000000..e543ef5 --- /dev/null +++ b/cpone-dashboard/menu/projects/route.go @@ -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) +} diff --git a/cpone-dashboard/menu/result/handler.go b/cpone-dashboard/menu/result/handler.go new file mode 100644 index 0000000..140dc1e --- /dev/null +++ b/cpone-dashboard/menu/result/handler.go @@ -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) + } +} diff --git a/cpone-dashboard/menu/result/query.go b/cpone-dashboard/menu/result/query.go new file mode 100644 index 0000000..6a56e45 --- /dev/null +++ b/cpone-dashboard/menu/result/query.go @@ -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 +} diff --git a/cpone-dashboard/menu/result/route.go b/cpone-dashboard/menu/result/route.go new file mode 100644 index 0000000..291dd3d --- /dev/null +++ b/cpone-dashboard/menu/result/route.go @@ -0,0 +1,7 @@ +package result + +import "github.com/go-chi/chi/v5" + +func Routes(r chi.Router) { + r.Get("/", Index) +} diff --git a/cpone-dashboard/scripts/README.md b/cpone-dashboard/scripts/README.md new file mode 100644 index 0000000..d1808b1 --- /dev/null +++ b/cpone-dashboard/scripts/README.md @@ -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. diff --git a/cpone-dashboard/scripts/demo_cleanup.sql b/cpone-dashboard/scripts/demo_cleanup.sql new file mode 100644 index 0000000..bdc1019 --- /dev/null +++ b/cpone-dashboard/scripts/demo_cleanup.sql @@ -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; diff --git a/cpone-dashboard/scripts/demo_live.sh b/cpone-dashboard/scripts/demo_live.sh new file mode 100644 index 0000000..9c545a3 --- /dev/null +++ b/cpone-dashboard/scripts/demo_live.sh @@ -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 diff --git a/cpone-dashboard/scripts/demo_seed.py b/cpone-dashboard/scripts/demo_seed.py new file mode 100644 index 0000000..fcf0792 --- /dev/null +++ b/cpone-dashboard/scripts/demo_seed.py @@ -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)) diff --git a/cpone-dashboard/static/css/custom.css b/cpone-dashboard/static/css/custom.css new file mode 100644 index 0000000..6cd0d69 --- /dev/null +++ b/cpone-dashboard/static/css/custom.css @@ -0,0 +1 @@ +/* custom styles — keep minimal, prefer tailwind classes */ diff --git a/cpone-dashboard/static/img/logo.png b/cpone-dashboard/static/img/logo.png new file mode 100644 index 0000000..77b7fd3 Binary files /dev/null and b/cpone-dashboard/static/img/logo.png differ diff --git a/cpone-dashboard/templates/abnormal/index.html b/cpone-dashboard/templates/abnormal/index.html new file mode 100644 index 0000000..8629fad --- /dev/null +++ b/cpone-dashboard/templates/abnormal/index.html @@ -0,0 +1,182 @@ +{{define "title"}}Abnormal Monitoring — CpOne{{end}} +{{define "header-title"}}Abnormal Monitoring{{end}} + +{{define "content"}} +{{$proj := .CurrentProject}} +{{$group := .Group}} + +
+
+
+

Ongoing Project

+

+ {{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}} +

+

+ {{$proj.Number}} • {{$proj.CorporateName}} • + {{$proj.StartDate | fmtDate}}{{$proj.EndDate | fmtDate}} +

+
+ + Ganti project + +
+
+ +
+
+ + Semua Kelainan + + {{range .Groups}} + + {{.}} + + {{end}} +
+
+ +
+
+

Total Peserta

+

{{.Summary.Total}}

+

Peserta aktif dalam project

+
+
+

Normal

+

{{.Summary.Normal}}

+

Tanpa temuan kelainan

+
+
+

Abnormal

+

{{.Summary.Abnormal}}

+

+ {{if eq $group ""}}Ada temuan kelainan{{else}}Kelainan: {{$group}}{{end}} +

+
+
+

Abnormal Rate

+

{{.Summary.AbnormalRate}}%

+

Persentase dari total peserta

+
+
+ +
+
+

Normal vs Abnormal

+
+
+ +
+

Distribusi Kelompok Usia

+
+
+ +
+

Gender

+
+
+ +
+

Departemen

+
+
+
+ + +{{end}} diff --git a/cpone-dashboard/templates/arrival/index.html b/cpone-dashboard/templates/arrival/index.html new file mode 100644 index 0000000..0d875a0 --- /dev/null +++ b/cpone-dashboard/templates/arrival/index.html @@ -0,0 +1,287 @@ +{{define "title"}}Arrival Tracking — CpOne{{end}} +{{define "header-title"}}Arrival Tracking{{end}} + +{{define "content"}} +{{$proj := .CurrentProject}} + +
+
+
+

Ongoing Project

+

+ {{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}} +

+

+ {{$proj.Number}} • {{$proj.CorporateName}} • + {{$proj.StartDate | fmtDate}}{{$proj.EndDate | fmtDate}} +

+
+
+ + Ganti project + +
+ + + + + +
+
+
+
+ +
+
+

Checked In

+

{{.Summary.CheckedIn}}

+

Sudah check-in pada tanggal ini

+
+
+

Not Check-in Yet

+

{{.Summary.Pending}}

+

Belum masuk ke area MCU

+
+
+

Total Schedule

+

{{.Summary.Total}}

+

Peserta yang dijadwalkan hari ini

+
+
+ +
+
+
+

Check-in Overview

+

Inner ring: checked-in summary, outer ring: distribution by department / posisi

+
+
+
+
+
+

Per Station Distribution

+

Current observed station loads

+
+
+
+
+ +
+
+ +
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+

Live Arrival List

+

Tanggal: {{.Date | fmtDate}}

+
+ {{len .FilteredRows}} ditampilkan +
+ +
+
+ Not Performed Yet + In Progress + Performed +
+
+ + {{if .FilteredRows}} + + +
+ {{range .FilteredRows}} +
+

{{.Name}}

+

{{if .InTime}}{{.InTime}}{{else}}-{{end}} • {{.NIP}} • {{.Department}}

+
+ {{if .Stations}} + {{range .Stations}} + {{if eq .Tone "success"}} + {{.Name}} + {{else if eq .Tone "warning"}} + {{.Name}} + {{else if eq .Tone "danger"}} + {{.Name}} + {{else}} + {{.Name}} + {{end}} + {{end}} + {{else}} + {{if eq .StatusTone "success"}} + {{.Status}} + {{else if eq .StatusTone "warning"}} + {{.Status}} + {{else}} + {{.Status}} + {{end}} + {{end}} +
+
+ {{end}} +
+ {{else}} +
+ Belum ada data arrival pada tanggal ini. +
+ {{end}} +
+ + +{{end}} diff --git a/cpone-dashboard/templates/auth/password.html b/cpone-dashboard/templates/auth/password.html new file mode 100644 index 0000000..cf167f8 --- /dev/null +++ b/cpone-dashboard/templates/auth/password.html @@ -0,0 +1,105 @@ +{{define "password"}} + + + + + + Ganti Password — CpOne Dashboard + + + + + + + + + +
+
+ + Logo + +
+ {{.Username}} + Logout +
+
+
+ +
+ + +
+

Ganti Password

+

Masukkan password saat ini untuk verifikasi, lalu isi password baru.

+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + + {{if .Success}} +
+ {{.Success}} +
+ {{end}} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ + +{{end}} diff --git a/cpone-dashboard/templates/dashboard/index.html b/cpone-dashboard/templates/dashboard/index.html new file mode 100644 index 0000000..6385f98 --- /dev/null +++ b/cpone-dashboard/templates/dashboard/index.html @@ -0,0 +1,306 @@ +{{define "title"}}Dashboard — CpOne{{end}} +{{define "header-title"}}MCU Live Dashboard{{end}} + +{{define "content"}} +{{$proj := .Project}} + + + +
+ + +
+
+
+
+

Ongoing Project

+ {{if .IsLive}} + + LIVE + + {{end}} +
+

{{$proj.Label}}

+

+ {{$proj.Number}} • {{$proj.CorporateName}} • + {{$proj.StartDate | fmtDate}}{{$proj.EndDate | fmtDate}} +

+
+
+ + Ganti project + +
+ + + + +
+ {{if gt .KPI.InvitedStaff 0}} +
+

Invited Staff

+

{{.KPI.InvitedStaff}}

+
+ {{end}} +
+
+
+ + +
+ {{range $i := seq 3}} +
+ {{end}} +
+ + +
+ +
+
+
+

Avg TAT by Hour

+

Check-in → Check-out

+
+ + {{.DateFrom | fmtDate}} + +
+ {{if gt .TAT.CheckedOut 0}} +

+ {{div .TAT.AvgMinutes 60}}h + {{mod .TAT.AvgMinutes 60}}m +

+

Average turnaround untuk pasien yang sudah selesai

+
+
+

Fastest

+

+ {{div .TAT.Fastest 60}}h {{mod .TAT.Fastest 60}}m +

+
+
+

Median

+

+ {{div .TAT.Median 60}}h {{mod .TAT.Median 60}}m +

+
+
+ {{else}} +

Belum ada data checkout pada tanggal ini.

+ {{end}} +
+ +
+
+

Average TAT by Hour

+ Hourly average across selected date(s) +
+
+
+ +
+ + +
+ +
+
+

Station Status

+ + Connecting... + +
+
Memuat data...
+
+ +
+
+

Arrival List

+ View all +
+
Memuat data...
+
+ +
+ + +
+
+
+

Arrival to Verification Trend by Hour

+ + {{.DateFrom | fmtDate}} + +
+
+
+
+ +
+ + + +
+ +
+
+

Semua Pasien

+

+
+ +
+ +
+
+ + + + + Memuat data... +
+
+
+
+ + +{{end}} diff --git a/cpone-dashboard/templates/dashboard/partials/arrivals.html b/cpone-dashboard/templates/dashboard/partials/arrivals.html new file mode 100644 index 0000000..9667bd9 --- /dev/null +++ b/cpone-dashboard/templates/dashboard/partials/arrivals.html @@ -0,0 +1,37 @@ +{{define "arrivals"}} +
+

Arrival List

+
+ {{if .IsLive}} + + Live + + {{end}} + +
+
+{{if .Rows}} + +{{else}} +
+ Belum ada arrival pada tanggal ini. +
+{{end}} +{{end}} diff --git a/cpone-dashboard/templates/dashboard/partials/kpi.html b/cpone-dashboard/templates/dashboard/partials/kpi.html new file mode 100644 index 0000000..b3ab2bb --- /dev/null +++ b/cpone-dashboard/templates/dashboard/partials/kpi.html @@ -0,0 +1,43 @@ +{{define "kpi"}} + +
+
+

Total Staff

+ + + +
+

{{.TotalStaff}}

+ {{if gt .InvitedStaff 0}} +

{{printf "%.1f%%" (pct .TotalStaff .InvitedStaff)}} dari invited

+ {{else}} +

Yang benar-benar datang

+ {{end}} +
+ + +
+
+

In Progress

+ + + +
+

{{.CheckedIn}}

+

Masih dalam proses

+
+ + +
+
+

Checked Out

+ + + +
+

{{.CheckedOut}}

+ {{if gt .CheckedIn 0}} +

{{printf "%.1f%%" (pct .CheckedOut .CheckedIn)}} selesai

+ {{end}} +
+{{end}} diff --git a/cpone-dashboard/templates/dashboard/partials/patients.html b/cpone-dashboard/templates/dashboard/partials/patients.html new file mode 100644 index 0000000..1e447c5 --- /dev/null +++ b/cpone-dashboard/templates/dashboard/partials/patients.html @@ -0,0 +1,80 @@ +{{define "patients"}} +{{if not .Patients}} +
+ + + +

Belum ada data pasien pada tanggal ini.

+
+{{else}} +
+ {{range .Patients}} + {{if and (gt .DoneCount 0) (eq .DoneCount (len .Stations))}} +
+ {{else}} +
+ {{end}} + +
+
+
+ {{.Name | initials}} +
+
+

{{.Name}}

+ {{if $.IsRange}}

{{.Date | fmtDate}}

{{end}} +
+
+
+ + Masuk: {{.InTime}} + + {{if .HasOut}} + + + Keluar: {{.OutTime}} + + {{else}} + + + Dalam proses + + {{end}} + + {{.DoneCount}}/{{len .Stations}} station selesai + +
+
+ + {{if .Stations}} +
+ {{range .Stations}} + {{if .Done}} + + + + {{.Station}} + + + Proses: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Selesai: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}} + + + {{else}} + + + + {{.Station}} + + + Proses: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Selesai: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}} + + + {{end}} + {{end}} +
+ {{end}} +
+ {{end}} +
+{{end}} +{{end}} diff --git a/cpone-dashboard/templates/dashboard/partials/stations.html b/cpone-dashboard/templates/dashboard/partials/stations.html new file mode 100644 index 0000000..34e4ed9 --- /dev/null +++ b/cpone-dashboard/templates/dashboard/partials/stations.html @@ -0,0 +1,53 @@ +{{define "stations"}} +
+

Station Status

+ {{if .IsLive}} + + Live + + {{end}} +
+{{if .Rows}} +
+ + + + + + + + + + + + {{range .Rows}} + + + + + + + + {{end}} + +
StationSudahBelumTotalProgress
+ {{.Station | stationShort}} + {{.Processed}}{{.Pending}}{{.Total}} +
+
+
+
+
+ + {{printf "%.0f%%" .Pct}} + +
+
+
+{{else}} +
+ Belum ada data station pada tanggal ini. +
+{{end}} +{{end}} diff --git a/cpone-dashboard/templates/layout/base.html b/cpone-dashboard/templates/layout/base.html new file mode 100644 index 0000000..1efab2c --- /dev/null +++ b/cpone-dashboard/templates/layout/base.html @@ -0,0 +1,90 @@ +{{define "base"}} + + + + + + {{block "title" .}}CpOne Dashboard{{end}} + + + + + + + + + + + + + + +
+
+
+ + Logo + +
+

{{block "header-title" .}}Dashboard{{end}}

+
+
+ +
+
+ +
+ {{block "content" .}}{{end}} +
+ + + +{{end}} diff --git a/cpone-dashboard/templates/login/index.html b/cpone-dashboard/templates/login/index.html new file mode 100644 index 0000000..9465fab --- /dev/null +++ b/cpone-dashboard/templates/login/index.html @@ -0,0 +1,129 @@ +{{define "login"}} + + + + + + Login — CpOne Dashboard + + + + + + + +
+ + + + + +
+
+ + +
+
+ Logo +
+
+ +
+
+

Masuk ke akun Anda

+

Gunakan username dan password yang terdaftar.

+
+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + +
+
+ + +
+ +
+ + +
+ + +
+
+ +

CpOne Dashboard — Laboratorium & Klinik Westerindo

+
+
+
+ + +{{end}} diff --git a/cpone-dashboard/templates/progress/index.html b/cpone-dashboard/templates/progress/index.html new file mode 100644 index 0000000..ebc9779 --- /dev/null +++ b/cpone-dashboard/templates/progress/index.html @@ -0,0 +1,196 @@ +{{define "title"}}Result Progress — CpOne{{end}} +{{define "header-title"}}Result Progress{{end}} + +{{define "content"}} +{{$proj := .CurrentProject}} + +
+
+
+

Ongoing Project

+

+ {{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}} +

+

+ {{$proj.Number}} • {{$proj.CorporateName}} • + {{$proj.StartDate | fmtDate}}{{$proj.EndDate | fmtDate}} +

+
+ + Ganti project + +
+
+ +
+
+

Total Patients

+

{{.Summary.Total}}

+

Peserta dalam project ini

+
+
+

Validated

+

{{.Summary.Validated}}

+

Resume sudah divalidasi dokter

+
+
+

Published

+

{{.Summary.Published}}

+

Hasil sudah dikirim ke peserta

+
+
+ +
+

Resume Progress

+
+
+
+ Validated + + {{.Summary.Validated}} / {{.Summary.Total}} + {{.ValidatedPct}}% + +
+
+
+
+
+
+
+ Published + + {{.Summary.Published}} / {{.Summary.Total}} + {{.PublishedPct}}% + +
+
+
+
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+

Patient Resume List

+

Data dari mcu_patient_resume_status

+
+ {{len .FilteredRows}} ditampilkan +
+ +
+
+ Validated + Not Validated + Published + Not Published +
+
+ + {{if .FilteredRows}} + + +
+ {{range .FilteredRows}} +
+
+
+

{{.Name}}

+

{{.NIP}} • {{.Posisi}}

+
+ {{if .ResumeStatus}} + {{.ResumeStatus}} + {{end}} +
+
+ {{if eq .Validated "Y"}} + Validated + {{else}} + Not Validated + {{end}} + {{if eq .Published "Y"}} + Published + {{else}} + Not Published + {{end}} +
+
+ {{end}} +
+ {{else}} +
+ Belum ada data resume untuk project ini. +
+ {{end}} +
+{{end}} diff --git a/cpone-dashboard/templates/projects/index.html b/cpone-dashboard/templates/projects/index.html new file mode 100644 index 0000000..124c42c --- /dev/null +++ b/cpone-dashboard/templates/projects/index.html @@ -0,0 +1,119 @@ +{{define "projects"}} + + + + + + Pilih Project — CpOne Dashboard + + + + + + + + + +
+
+ + Logo + +
+ {{.Username}} + Logout +
+
+
+ +
+ +
+

Pilih Project

+

Pilih salah satu project MCU untuk mulai monitoring.

+
+ + {{if .CurrentProject}} +
+ Project aktif saat ini: + {{if .CurrentProject.Label}}{{.CurrentProject.Label}}{{else}}MCU #{{.CurrentProject.McuID}}{{end}} + + Buka dashboard +
+ {{end}} + + {{if not .Projects}} +
+ + + +

Belum ada project yang di-assign

+

Hubungi administrator untuk mendapatkan akses.

+
+ {{else}} + + {{end}} + +
+ + +{{end}} diff --git a/cpone-dashboard/templates/result/index.html b/cpone-dashboard/templates/result/index.html new file mode 100644 index 0000000..188fd3f --- /dev/null +++ b/cpone-dashboard/templates/result/index.html @@ -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 */}} +
+
+
+

Ongoing Project

+

+ {{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}} +

+

+ {{$proj.Number}} • {{$proj.CorporateName}} • + {{$proj.StartDate | fmtDate}}{{$proj.EndDate | fmtDate}} +

+
+ + Ganti project + +
+
+ +{{/* Section 2: Summary cards */}} +
+
+

Total Patients

+

{{.Summary.Total}}

+

Peserta dalam project ini

+
+
+

Has PDF

+

{{.Summary.HasPDF}}

+

Laporan hasil sudah tersedia

+
+
+ +{{/* Section 3: Filter form */}} +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +{{/* Section 4: Patient list */}} +
+
+
+

Patient Result List

+

Data dari published_mcu_dashboard_sync

+
+ {{len .FilteredRows}} ditampilkan +
+ + {{if .FilteredRows}} + + +
+ {{range .FilteredRows}} +
+
+
+

{{.Name}}

+

{{.NIP}} • {{.Posisi}}

+ {{if .ReportDate}}

{{.ReportDate | fmtDate}}

{{end}} +
+ {{if .FileUrl}} + + {{else}} + No PDF + {{end}} +
+
+ {{end}} +
+ {{else}} +
+ Belum ada data untuk project ini. +
+ {{end}} +
+ +{{/* PDF Modal */}} + + + +{{end}} diff --git a/docs/superpowers/plans/2026-04-30-result-menu.md b/docs/superpowers/plans/2026-04-30-result-menu.md new file mode 100644 index 0000000..ee50cba --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-result-menu.md @@ -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 */}} +
+
+
+

Ongoing Project

+

+ {{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}} +

+

+ {{$proj.Number}} • {{$proj.CorporateName}} • + {{$proj.StartDate | fmtDate}}{{$proj.EndDate | fmtDate}} +

+
+ + Ganti project + +
+
+ +{{/* Section 2: Summary cards */}} +
+
+

Total Patients

+

{{.Summary.Total}}

+

Peserta dalam project ini

+
+
+

Has PDF

+

{{.Summary.HasPDF}}

+

Laporan hasil sudah tersedia

+
+
+ +{{/* Section 3: Filter form */}} +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +{{/* Section 4: Patient list */}} +
+
+
+

Patient Result List

+

Data dari published_mcu_dashboard_sync

+
+ {{len .FilteredRows}} ditampilkan +
+ + {{if .FilteredRows}} + + +
+ {{range .FilteredRows}} +
+
+
+

{{.Name}}

+

{{.NIP}} • {{.Posisi}}

+ {{if .ReportDate}}

{{.ReportDate | fmtDate}}

{{end}} +
+ {{if .FileUrl}} + + View PDF + + {{else}} + No PDF + {{end}} +
+
+ {{end}} +
+ {{else}} +
+ Belum ada data untuk project ini. +
+ {{end}} +
+{{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` diff --git a/docs/superpowers/specs/2026-04-27-mcu-dashboard-design.md b/docs/superpowers/specs/2026-04-27-mcu-dashboard-design.md new file mode 100644 index 0000000..49e9d27 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-mcu-dashboard-design.md @@ -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 diff --git a/docs/superpowers/specs/2026-04-30-result-menu-design.md b/docs/superpowers/specs/2026-04-30-result-menu-design.md new file mode 100644 index 0000000..5ab7dbd --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-result-menu-design.md @@ -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.