Initial commit

This commit is contained in:
sas.fajri
2026-04-30 14:27:01 +07:00
commit e29e943c27
70 changed files with 8909 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
{
"permissions": {
"allow": [
"Bash(go get *)",
"Bash(go mod *)",
"Bash(GOTOOLCHAIN=local go get *)",
"Bash(brew info *)",
"Bash(gunzip)",
"Bash(tar -x -C /tmp/go123)",
"Bash(mkdir -p /tmp/go123)",
"Bash(gunzip -c Payload)",
"Bash(/tmp/go123/usr/local/go/bin/go version *)",
"Bash(export PATH=\"/tmp/go123/usr/local/go/bin:$PATH\")",
"Bash(export GOROOT=\"/tmp/go123/usr/local/go\")",
"Bash(go build *)",
"Bash(pkill -f \"ssh -f -N -L 3307\")",
"Bash(ssh *)",
"Bash(go run *)",
"Bash(curl -s \"http://localhost:8080/dashboard?date=2024-09-26\")",
"Bash(curl -s \"http://localhost:8080/dashboard/kpi?date=2024-09-26\")",
"Bash(curl -s \"http://localhost:8080/dashboard/stations?date=2024-09-26\")",
"Bash(sed -i '' 's/:3306/:3307/' /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard/.env)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" \"http://localhost:8080/dashboard?date=2024-09-26\")",
"Bash(pkill -f \"go run .\")",
"Bash(curl -s \"http://localhost:8080/\" -o /dev/null -w \"%{http_code}\\\\n\")",
"Bash(curl -s \"http://localhost:8080/dashboard/\" -o /dev/null -w \"%{http_code}\\\\n\")",
"Read(//private/tmp/**)",
"Bash(curl -s \"http://localhost:8080/\" -o /dev/null -w \"root: %{http_code}\\\\n\")",
"Bash(curl -s -N --max-time 3 \"http://localhost:8080/dashboard/stream?date=2024-09-26\")",
"Bash(curl -s -N --max-time 4 -H \"Accept: text/event-stream\" \"http://localhost:8080/dashboard/stream?date=2024-09-26\")",
"Bash(pkill -f \"__debug_bin\")",
"Bash(lsof -ti:8080)",
"Bash(xargs kill *)",
"Bash(curl -s -N --max-time 5 \"http://localhost:8080/dashboard/stream?date=2024-09-26\")",
"Bash(mysql *)",
"Bash(mkdir -p /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard/static/img)",
"Bash(cp /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard/assets/logo.png /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard/static/img/logo.png)",
"Bash(lsof -nP -iTCP:3307 -sTCP:LISTEN)",
"Bash(lsof -nP -iTCP:8080 -sTCP:LISTEN)"
]
}
}

1
PLAN/draft-cpone Submodule

Submodule PLAN/draft-cpone added at 9069dc0613

View File

@@ -0,0 +1,5 @@
APP_PORT=8080
DB_DSN=user:password@tcp(127.0.0.1:3306)/cpone_dashboard?parseTime=true&loc=Local
AUTH_SECRET=random-32-char-string-ganti-ini
PDF_BASE_URL=http://your-server/dashboard-files/
BASE_PATH= # kosong = root, isi "/cpone-dashboard" untuk sub-path

3
cpone-dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
cpone-dashboard
cpone-dashboard-linux

337
cpone-dashboard/DEPLOY.md Normal file
View File

@@ -0,0 +1,337 @@
# Deployment Guide — cpone-dashboard
Dokumen ini mencatat semua yang dilakukan dari awal sampai app berjalan di server, lengkap dengan langkah untuk deploy ulang atau deploy ke server baru.
---
## Ringkasan Yang Dikerjakan
### Fitur yang diimplementasi
| Fitur | File |
|-------|------|
| Result menu — list pasien + View PDF modal | `menu/result/query.go`, `menu/result/handler.go`, `templates/result/index.html` |
| PDF base URL via env | `config/config.go`, `.env`, `main.go` |
| BASE_PATH — akses via sub-path tanpa port | `config/config.go`, semua handler, semua template |
| Login path `/mcu-login` (hindari konflik Apache) | `menu/auth/route.go`, `menu/auth/handler.go`, `menu/auth/middleware.go`, `templates/login/index.html` |
| Demo data seed (1500 pasien MCU DEMO 2026) | `scripts/demo_seed.py` |
| Live simulation script | `scripts/demo_live.sh` |
| Deploy ke devcpone via systemd user service | `Makefile`, `.config/systemd/user/cpone-dashboard.service` |
### Perubahan kode
```
config/config.go → tambah PDFBaseURL + BasePath field
.env / .env.example → tambah PDF_BASE_URL + BASE_PATH
main.go → b() template func, mount routes kondisional,
SetBasePath ke semua package
menu/auth/route.go → /login → /mcu-login
menu/auth/handler.go → SetBasePath, redirect pakai basePath
menu/auth/middleware.go → redirect ke basePath+/mcu-login
menu/result/query.go → full implementation (ResultRow, GetResultRows, filter)
menu/result/handler.go → full implementation (pageData, Index, SetPDFBaseURL, SetBasePath)
menu/dashboard/handler.go → SetBasePath, redirect pakai basePath
menu/arrival/handler.go → SetBasePath, redirect pakai basePath
menu/progress/handler.go → SetBasePath, redirect pakai basePath
menu/abnormal/handler.go → SetBasePath, redirect pakai basePath
menu/projects/handler.go → SetBasePath, redirect pakai basePath
templates/layout/base.html → semua nav link pakai {{b "/..."}}
templates/login/index.html → src + action pakai {{b "/..."}}
templates/auth/password.html → semua link pakai {{b "/..."}}
templates/projects/index.html → semua link pakai {{b "/..."}}
templates/dashboard/index.html → sse-connect, form action, fetch() pakai {{b "/..."}}
templates/arrival/index.html → link + form action pakai {{b "/..."}}
templates/progress/index.html → link + form action pakai {{b "/..."}}
templates/abnormal/index.html → tab links pakai {{b "/..."}}
templates/result/index.html → full template (4 section + PDF modal dialog)
scripts/demo_seed.py → generate 1500 pasien demo + kelainan + published
scripts/demo_live.sh → simulasi live MCU (checkin, station, validasi, publish)
scripts/demo_cleanup.sql → hapus semua data demo
scripts/README.md → panduan penggunaan demo scripts
Makefile → update deploy target (systemd), tambah logs + status
```
---
## Arsitektur
```
┌─────────────────────────────────────────────────────────┐
│ devcpone server │
│ │
│ cpone-dashboard (Go binary, port 8090) │
│ │ │
│ └── MySQL 3306 → cpone_dashboard (DB utama) │
│ ├── mcu_project │
│ ├── mcu_patient │
│ ├── mcu_checkinout │
│ ├── mcu_station_progress │
│ ├── mcu_patient_resume_status │
│ ├── published_mcu_dashboard_sync │
│ ├── kelainan_details │
│ └── ... (lihat db/migrations/) │
│ │
│ /home/one/project/one/dashboard-files/ ← file PDF │
└─────────────────────────────────────────────────────────┘
Di laptop (dev):
DB diakses via SSH tunnel → localhost:3307 → devcpone:3306
make start (buka tunnel + jalankan app lokal)
```
**Tech stack:** Go 1.21, Chi router, HTML templates (embed), HTMX, Tailwind CDN, ECharts CDN, MySQL 8
---
## Prerequisites
### Di laptop (build)
- Go 1.21+ (`/usr/local/go/bin/go`)
- SSH access ke `one@devcpone.aplikasi.web.id`
- Python 3 (untuk generate demo data)
### Di server tujuan
- Ubuntu/Linux x86_64
- MySQL 8 dengan database `cpone_dashboard` sudah ada dan migration sudah dijalankan
- Systemd user service tersedia (`systemctl --user`)
- User dengan akses ke MySQL (`admin:password@127.0.0.1:3306`)
- Direktori untuk file PDF: `/path/to/dashboard-files/` bisa diakses via HTTP
---
## Deploy Pertama Kali (Server Baru)
### 1. Persiapan database
Pastikan database `cpone_dashboard` sudah ada dan semua migration dijalankan:
```bash
ssh user@server "mysql -u admin -pPASSWORD -e 'SHOW DATABASES;'"
```
Jalankan migration satu per satu jika belum:
```bash
ssh user@server "mysql -u admin -pPASSWORD cpone_dashboard < /path/migration/001_init_schema.sql"
# dst untuk 002 - 011
```
Migration ada di: `db/migrations/001_init_schema.sql` sampai `011_patient_resume_status.sql`
### 2. Buat direktori deploy di server
```bash
ssh user@server "mkdir -p /home/user/project/cpone-dashboard"
```
### 3. Buat file .env di server
```bash
ssh user@server "cat > /home/user/project/cpone-dashboard/.env << 'EOF'
APP_PORT=8090
DB_DSN=admin:PASSWORD@tcp(127.0.0.1:3306)/cpone_dashboard?parseTime=true&loc=Local
AUTH_SECRET=ganti-dengan-string-random-32-karakter
PDF_BASE_URL=http://domain-server/dashboard-files/
EOF"
```
Sesuaikan:
- `APP_PORT` — port yang belum dipakai di server (cek dengan `ss -tlnp`)
- `DB_DSN` — sesuaikan host, port, user, password, nama database
- `AUTH_SECRET` — string random minimal 32 karakter, bisa generate: `openssl rand -hex 32`
- `PDF_BASE_URL` — URL publik ke folder tempat file PDF disimpan
### 4. Buat systemd user service
```bash
ssh user@server "mkdir -p ~/.config/systemd/user && cat > ~/.config/systemd/user/cpone-dashboard.service << 'EOF'
[Unit]
Description=CpOne Dashboard
ConditionPathExists=/home/user/project/cpone-dashboard/cpone-dashboard
After=network.target
StartLimitIntervalSec=60
[Service]
Type=simple
Restart=on-failure
RestartSec=10
WorkingDirectory=/home/user/project/cpone-dashboard
ExecStart=/home/user/project/cpone-dashboard/cpone-dashboard
EnvironmentFile=/home/user/project/cpone-dashboard/.env
[Install]
WantedBy=default.target
EOF"
```
Ganti `/home/user` dengan home directory user yang dipakai.
### 5. Update Makefile untuk server baru
Edit bagian atas `Makefile`:
```makefile
SERVER=user@server-baru.domain.com
DEPLOY_DIR=/home/user/project/cpone-dashboard
```
### 6. Build dan deploy
```bash
cd /path/to/cpone-dashboard
make deploy
```
Perintah ini:
1. Cross-compile binary untuk linux/amd64
2. Upload ke server via SCP
3. Restart systemd service
### 7. Enable service agar auto-start saat reboot
```bash
ssh user@server "systemctl --user enable cpone-dashboard"
```
### 8. Verifikasi
```bash
make status # cek status service
make logs # lihat log live
# Atau langsung dari browser:
# http://server-domain:PORT
```
---
## Deploy Ulang (Update Kode)
Cukup jalankan:
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
make deploy
```
Itu saja. Build → upload → restart otomatis.
---
## Development Lokal
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
# Jalankan (buka SSH tunnel ke devcpone:3306 → localhost:3307, lalu start app)
make start
# Stop (tutup tunnel + kill app)
make stop
# Akses di browser: http://localhost:8080
```
File `.env` lokal menggunakan:
```
DB_DSN=...@tcp(127.0.0.1:3307)/cpone_dashboard... ← via tunnel port 3307
APP_PORT=8080
PDF_BASE_URL=http://devcpone.aplikasi.web.id/dashboard-files/
```
---
## Konfigurasi .env
| Key | Keterangan | Contoh |
|-----|-----------|--------|
| `APP_PORT` | Port HTTP server | `8090` |
| `DB_DSN` | MySQL DSN (Go format) | `user:pass@tcp(host:3306)/dbname?parseTime=true&loc=Local` |
| `AUTH_SECRET` | Secret untuk session cookie JWT | string random 32+ karakter |
| `PDF_BASE_URL` | Base URL untuk file PDF resume individu | `http://domain/dashboard-files/` |
`PDF_BASE_URL` harus diakhiri `/`. File PDF disimpan di server dengan path relatif misal `2026/04/R2604xxxx_resume_individu.pdf`, sehingga full URL = `PDF_BASE_URL + relative_path`.
---
## Struktur File di Server
```
/home/one/project/cpone-dashboard/
├── cpone-dashboard ← binary (hasil build)
└── .env ← konfigurasi (jangan commit ke git)
/home/one/.config/systemd/user/
└── cpone-dashboard.service ← systemd unit file
/home/one/project/one/dashboard-files/
└── 2026/
└── 04/
└── R2604xxxx_resume_individu.pdf ← file PDF hasil MCU
```
---
## Troubleshooting
**Service gagal start:**
```bash
journalctl --user -u cpone-dashboard -n 30 --no-pager
```
**Port sudah dipakai:**
```bash
ss -tlnp | grep PORT
# Ganti APP_PORT di .env, lalu restart
systemctl --user restart cpone-dashboard
```
**Terlalu banyak restart (failed state):**
```bash
systemctl --user stop cpone-dashboard
systemctl --user reset-failed cpone-dashboard
systemctl --user start cpone-dashboard
```
**Database tidak bisa connect:**
- Pastikan `DB_DSN` di `.env` benar
- Cek MySQL berjalan: `mysql -u admin -pPASS -e "SELECT 1;"`
- Kalau pakai tunnel (dev lokal): pastikan tunnel aktif
**Binary tidak update:**
```bash
# Cek binary sudah ter-upload
ssh one@devcpone.aplikasi.web.id "ls -lh /home/one/project/cpone-dashboard/cpone-dashboard"
# Force build ulang
cd /path/to/repo && make deploy
```
---
## Demo Data (Opsional)
Untuk demo ke client dengan data realistis (MCU PROJECT DEMO 2026, 1500 pasien):
```bash
# Seed data
python3 scripts/demo_seed.py | ssh one@devcpone.aplikasi.web.id \
"mysql -u admin -pSasone\!102938 cpone_dashboard"
# Buat PDF demo di server
ssh one@devcpone.aplikasi.web.id "
mkdir -p /home/one/project/one/dashboard-files/2026/04
SRC=/home/one/project/one/dashboard-files/2024/09/R2409170003_resume_individu.pdf
for i in \$(seq 1 80); do
cp \"\$SRC\" \"/home/one/project/one/dashboard-files/2026/04/R2604\$(printf '%04d' \$i)_resume_individu.pdf\"
done
"
# Upload live script
scp scripts/demo_live.sh one@devcpone.aplikasi.web.id:/home/one/demo_live.sh
ssh one@devcpone.aplikasi.web.id "chmod +x /home/one/demo_live.sh"
# Jalankan simulasi live saat demo
ssh one@devcpone.aplikasi.web.id "/home/one/demo_live.sh 5"
```
Detail lengkap lihat `scripts/README.md`.

47
cpone-dashboard/Makefile Normal file
View File

@@ -0,0 +1,47 @@
BINARY=cpone-dashboard
SERVER=one@devcpone.aplikasi.web.id
DEPLOY_DIR=/home/one/project/cpone-dashboard
GO ?= go
.PHONY: run build deploy local stop start restart
run:
$(GO) run .
local:
GOCACHE=/tmp/cpone-gocache $(GO) run .
start:
@if lsof -nP -iTCP:3307 -sTCP:LISTEN >/dev/null 2>&1; then \
echo "tunnel 3307 already running"; \
else \
echo "starting tunnel 3307 -> devcpone"; \
ssh -f -N -L 3307:127.0.0.1:3306 one@devcpone.aplikasi.web.id; \
fi
@echo "starting dashboard on http://localhost:8080/dashboard"
GOCACHE=/tmp/cpone-gocache $(GO) run .
stop:
@pids="$$(lsof -t -iTCP:8080 -sTCP:LISTEN 2>/dev/null) $$(lsof -t -iTCP:3307 -sTCP:LISTEN 2>/dev/null)"; \
if [ -z "$$pids" ]; then \
echo "nothing to stop"; \
else \
echo "$$pids" | tr ' ' '\n' | sort -u | xargs kill; \
echo "stopped local dashboard and tunnel"; \
fi
restart: stop start
build:
GOOS=linux GOARCH=amd64 $(GO) build -o $(BINARY)-linux .
deploy: build
scp $(BINARY)-linux $(SERVER):$(DEPLOY_DIR)/$(BINARY)
ssh $(SERVER) "chmod +x $(DEPLOY_DIR)/$(BINARY) && systemctl --user restart cpone-dashboard"
@echo "deployed → https://devcpone.aplikasi.web.id/cpone-dashboard"
logs:
ssh $(SERVER) "journalctl --user -u cpone-dashboard -f --no-pager"
status:
ssh $(SERVER) "systemctl --user status cpone-dashboard --no-pager"

244
cpone-dashboard/README.md Normal file
View File

@@ -0,0 +1,244 @@
# CPONE Dashboard
Web dashboard berbasis Go untuk monitoring CPONE.
## Prerequisites
- Go 1.22+
- Akses SSH ke `devcpone.aplikasi.web.id` (untuk koneksi ke database)
## Cara Menjalankan
### 1. Setup `.env`
Salin `.env.example` menjadi `.env`:
```bash
cp .env.example .env
```
Isi `.env` dengan credential yang sesuai:
```
APP_PORT=8080
DB_DSN=user:password@tcp(127.0.0.1:3307)/cpone_dashboard?parseTime=true&loc=Local
```
> Port `3307` digunakan karena database diakses melalui SSH tunnel (bukan langsung port 3306).
### 2. Buka SSH Tunnel ke Database
Jalankan perintah ini di terminal **terpisah** (atau di background):
```bash
ssh -f -N -L 3307:127.0.0.1:3306 one@devcpone.aplikasi.web.id
```
Kalau muncul error `Address already in use`, berarti tunnel sudah aktif — lanjut ke langkah berikutnya.
### 3. Jalankan App
```bash
go run .
```
App berjalan di: [http://localhost:8080/dashboard](http://localhost:8080/dashboard)
---
## Cara Menghentikan
Hentikan tunnel dan app sekaligus:
```bash
make stop
```
Atau manual — cari dan kill prosesnya:
```bash
# Hentikan app (port 8080)
lsof -ti:8080 | xargs kill
# Hentikan SSH tunnel (port 3307)
lsof -ti:3307 | xargs kill
```
---
## Kustomisasi Tampilan
### Ganti Logo
Timpa file berikut dengan logo baru (format PNG, background transparan atau putih):
```
static/img/logo.png
```
Tidak perlu ubah kode — logo langsung berubah di seluruh halaman.
### Ganti Warna Utama
Semua warna primary dikendalikan dari satu tempat di `templates/layout/base.html`:
```js
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: '#eef0fb',
100: '#dde2f7',
...
500: '#3b50a0', // <-- warna utama (header, badge, dll)
...
}
}
}
}
}
```
Ubah nilai hex di blok `brand`, semua elemen yang pakai `brand-*` ikut berubah.
---
## Autentikasi & Manajemen User
### Alur Login
```
Browser Go (auth middleware) DB (dashboard_user)
│ │ │
│ GET /dashboard │ │
│ ────────────────────────► │ cek cookie session │
│ │ (tidak ada / invalid) │
│ redirect /login ◄────── │ │
│ │ │
│ POST /login │ │
│ username=budi ───► │ SELECT User_Password, │
│ password=xxx │ User_Salt ──► │
│ │ ◄──────── │ hash + salt
│ │ SHA2(salt:password, 256) │
│ │ cocokkan dengan hash │
│ │ │
│ set cookie + redirect ◄─ │ ✓ cocok → set session cookie│
│ ke /dashboard │ │
```
### Alur Password Hashing
Password tidak pernah disimpan dalam bentuk plain text. Setiap user punya **salt unik** yang di-generate otomatis saat insert/update.
```
sp_upsert_dashboard_user('budi', 'password123', 'Budi Santoso')
├─ v_salt = UUID() → contoh: "a91f-yyyy-..."
├─ v_hash = SHA2("a91f-yyyy-...:password123", 256)
└─ simpan ke DB:
User_Password = "f3c2..." ← hash
User_Salt = "a91f-..." ← salt
```
Efeknya: dua user dengan password yang sama tetap menghasilkan hash yang berbeda di DB, karena salt-nya berbeda.
### Menambah User Baru
```sql
-- Error jika username sudah terdaftar
CALL sp_insert_dashboard_user('budi', 'password123', 'Budi Santoso');
```
### Reset Password
```sql
-- Error jika username tidak ditemukan
CALL sp_reset_dashboard_user_password('budi', 'passwordbaru');
```
### Nonaktifkan User
```sql
UPDATE dashboard_user SET User_IsActive = 'N' WHERE User_Username = 'budi';
```
### Struktur Tabel `dashboard_user`
| Kolom | Tipe | Keterangan |
|---|---|---|
| `User_ID` | INT | Primary key, auto increment |
| `User_Username` | VARCHAR(50) | Login username, unique |
| `User_Password` | VARCHAR(64) | SHA2-256 hash |
| `User_Salt` | VARCHAR(36) | UUID salt, unik per user/reset |
| `User_DisplayName` | VARCHAR(100) | Nama yang ditampilkan di header |
| `User_IsActive` | CHAR(1) | `Y` = aktif, `N` = nonaktif |
---
## Mapping User ke MCU Project
Satu user bisa diassign ke lebih dari satu `mcu_project`. Relasi disimpan di tabel `dashboard_user_project`.
### Relasi
```
dashboard_user dashboard_user_project mcu_project
────────────── ────────────────────── ───────────
User_ID ◄──── UserProj_UserID Mcu_ProjectMcuID
UserProj_McuID ────►
UserProj_IsActive
```
### Assign / Cabut Akses Project
```sql
-- Assign user ke satu project
CALL sp_assign_user_project('budi', 101);
-- Assign ke project lain (bisa lebih dari satu)
CALL sp_assign_user_project('budi', 205);
-- Cabut akses dari satu project (soft delete, data tetap ada)
CALL sp_remove_user_project('budi', 101);
```
### Lihat Project yang Bisa Diakses User
```sql
SELECT
u.User_Username,
u.User_DisplayName,
p.Mcu_ProjectMcuID,
p.Mcu_ProjectLabel,
p.Mcu_ProjectCorporateName,
up.UserProj_IsActive
FROM dashboard_user_project up
JOIN dashboard_user u ON u.User_ID = up.UserProj_UserID
JOIN mcu_project p ON p.Mcu_ProjectMcuID = up.UserProj_McuID
WHERE u.User_Username = 'budi'
AND up.UserProj_IsActive = 'Y';
```
### Struktur Tabel `dashboard_user_project`
| Kolom | Tipe | Keterangan |
|---|---|---|
| `UserProj_ID` | INT | Primary key, auto increment |
| `UserProj_UserID` | INT | Referensi ke `dashboard_user.User_ID` |
| `UserProj_McuID` | INT | Referensi ke `mcu_project.Mcu_ProjectMcuID` |
| `UserProj_IsActive` | CHAR(1) | `Y` = aktif, `N` = dicabut |
---
## Shortcut via Makefile
| Perintah | Keterangan |
|---|---|
| `make start` | Buka SSH tunnel + jalankan app |
| `make stop` | Hentikan app dan SSH tunnel |
| `make restart` | Stop lalu start ulang |
| `make run` | Jalankan app saja (tanpa buka tunnel) |
| `make build` | Build binary untuk Linux |
| `make deploy` | Build + deploy ke server |

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,37 @@
package config
import (
"log"
"os"
"github.com/joho/godotenv"
)
type Config struct {
AppPort string
DBDSN string
AuthSecret string
PDFBaseURL string
BasePath string // e.g. "/cpone-dashboard" or "" for root
}
func Load() *Config {
if err := godotenv.Load(); err != nil {
log.Println("no .env file, reading from environment")
}
return &Config{
AppPort: getEnv("APP_PORT", "8080"),
DBDSN: getEnv("DB_DSN", ""),
AuthSecret: getEnv("AUTH_SECRET", "cpone-change-this-secret"),
PDFBaseURL: getEnv("PDF_BASE_URL", ""),
BasePath: getEnv("BASE_PATH", ""),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

33
cpone-dashboard/db/db.go Normal file
View File

@@ -0,0 +1,33 @@
package db
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
var DB *sql.DB
func Connect(dsn string) error {
if dsn == "" {
return fmt.Errorf("DB_DSN is not set")
}
conn, err := sql.Open("mysql", dsn)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
if err := conn.Ping(); err != nil {
return fmt.Errorf("ping db: %w", err)
}
conn.SetMaxOpenConns(25)
conn.SetMaxIdleConns(10)
DB = conn
log.Println("database connected: cpone_dashboard")
return nil
}

View File

@@ -0,0 +1,123 @@
-- Migration 001: Initial schema for cpone_dashboard
-- Naming convention: table=snake_case, column=Prefix_PascalCase (mengikuti cpone)
-- Tidak menggunakan foreign key constraint
-- ============================================================
-- mcu_project
-- Source: cpone.mgm_mcu JOIN cpone.corporate
-- ============================================================
CREATE TABLE IF NOT EXISTS mcu_project (
Mcu_ProjectID INT AUTO_INCREMENT PRIMARY KEY,
Mcu_ProjectMcuID INT NOT NULL, -- Mgm_McuID
Mcu_ProjectCorporateID INT NOT NULL, -- Mgm_McuCorporateID
Mcu_ProjectCorporateName VARCHAR(255), -- corporate.CorporateName
Mcu_ProjectNumber VARCHAR(50), -- Mgm_McuNumber
Mcu_ProjectLabel VARCHAR(255), -- Mgm_McuLabel
Mcu_ProjectBranchID INT DEFAULT 0, -- Mgm_McuM_BranchID
Mcu_ProjectStartDate DATE, -- Mgm_McuStartDate
Mcu_ProjectEndDate DATE, -- Mgm_McuEndDate
Mcu_ProjectIsActive CHAR(1) DEFAULT 'Y', -- Mgm_McuIsActive
Mcu_ProjectTotalParticipant INT DEFAULT 0, -- Mgm_McuTotalParticipant
Mcu_ProjectSyncedAt DATETIME,
UNIQUE KEY uq_mcu_id (Mcu_ProjectMcuID),
INDEX idx_is_active (Mcu_ProjectIsActive),
INDEX idx_dates (Mcu_ProjectStartDate, Mcu_ProjectEndDate)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- mcu_patient
-- Source: cpone.mcu_preregister_patients
-- ============================================================
CREATE TABLE IF NOT EXISTS mcu_patient (
Mcu_PatientID INT AUTO_INCREMENT PRIMARY KEY,
Mcu_PatientPreregisterID INT NOT NULL, -- Mcu_PreregisterPatientsID
Mcu_PatientMcuID INT NOT NULL, -- Mcu_PreregisterPatientsMgm_McuID
Mcu_PatientName VARCHAR(150), -- Mcu_PreregisterPatientsPatientName
Mcu_PatientNIP VARCHAR(50), -- Mcu_PreregisterPatientsNIP
Mcu_PatientGender VARCHAR(10), -- Mcu_PreregisterPatientsGender
Mcu_PatientDOB DATE, -- Mcu_PreregisterPatientsDOB
Mcu_PatientDepartment VARCHAR(500), -- Mcu_PreregisterPatientsDepartment
Mcu_PatientDivision VARCHAR(500), -- Mcu_PreregisterPatientsDivisi
Mcu_PatientPosisi VARCHAR(500), -- Mcu_PreregisterPatientsPosisi
Mcu_PatientIsRegistered CHAR(1) DEFAULT 'N', -- Mcu_PreregisterPatientsIsRegistered
Mcu_PatientOrderID INT DEFAULT 0, -- Mcu_PreregisterPatientsT_OrderHeaderID
Mcu_PatientIsActive CHAR(1) DEFAULT 'Y', -- Mcu_PreregisterPatientsIsActive
Mcu_PatientSyncedAt DATETIME,
UNIQUE KEY uq_preregister_id (Mcu_PatientPreregisterID),
INDEX idx_mcu_id (Mcu_PatientMcuID),
INDEX idx_order_id (Mcu_PatientOrderID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- mcu_patient_schedule
-- Source: cpone.mcu_preregister_date
-- Catatan: tidak semua pasien punya jadwal (opsional)
-- ============================================================
CREATE TABLE IF NOT EXISTS mcu_patient_schedule (
Mcu_PatientScheduleID INT AUTO_INCREMENT PRIMARY KEY,
Mcu_PatientSchedulePreregisterID INT NOT NULL, -- Mcu_PreregisterDateMcu_PreregisterPatientsID
Mcu_PatientScheduleDate DATE NOT NULL, -- Mcu_PreregisterDateCheckinSchedule
Mcu_PatientScheduleIsActive CHAR(1) DEFAULT 'Y', -- Mcu_PreregisterDateIsActive
Mcu_PatientScheduleSyncedAt DATETIME,
INDEX idx_preregister_id (Mcu_PatientSchedulePreregisterID),
INDEX idx_schedule_date (Mcu_PatientScheduleDate)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- mcu_checkinout
-- Source: cpone.preregister_checkin_checkout
-- Catatan: 1 pasien bisa punya > 1 baris (multi-hari)
-- ============================================================
CREATE TABLE IF NOT EXISTS mcu_checkinout (
Mcu_CheckinoutID INT AUTO_INCREMENT PRIMARY KEY,
Mcu_CheckinoutCheckinoutID INT NOT NULL, -- PreregisterCheckInCheckOutID
Mcu_CheckinoutPreregisterID INT NOT NULL, -- PreregisterCheckInCheckOutPreregisterID
Mcu_CheckinoutOrderID INT DEFAULT 0, -- PreregisterCheckInCheckOutT_OrderHeaderID
Mcu_CheckinoutDate DATE NOT NULL, -- PreregisterCheckInCheckOutDate
Mcu_CheckinoutInTime TIME, -- PreregisterCheckInCheckOutInTime
Mcu_CheckinoutOutTime TIME, -- PreregisterCheckInCheckOutOutTime
Mcu_CheckinoutOutUserID INT DEFAULT 0, -- PreregisterCheckInCheckOutOutUserID
Mcu_CheckinoutNextDate DATE, -- PreregisterCheckInCheckOutNextPreregisterDate
Mcu_CheckinoutNote TEXT, -- PreregisterCheckInCheckOutNote
Mcu_CheckinoutIsActive CHAR(1) DEFAULT 'Y', -- PreregisterCheckInCheckOutIsActive
Mcu_CheckinoutSyncedAt DATETIME,
UNIQUE KEY uq_checkinout_segment (
Mcu_CheckinoutPreregisterID,
Mcu_CheckinoutDate,
Mcu_CheckinoutInTime
),
INDEX idx_preregister_id (Mcu_CheckinoutPreregisterID),
INDEX idx_date (Mcu_CheckinoutDate),
INDEX idx_order_id (Mcu_CheckinoutOrderID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- mcu_station_progress (Phase 2)
-- Source: cpone.t_ordersample (lab)
-- + cpone.t_orderdetail->t_test->group_resultdetail->group_result (nonlab)
-- ETL flatten kedua sumber menjadi satu tabel
-- ============================================================
CREATE TABLE IF NOT EXISTS mcu_station_progress (
Mcu_StationProgressID INT AUTO_INCREMENT PRIMARY KEY,
Mcu_StationProgressOrderID INT NOT NULL, -- T_OrderHeaderID
Mcu_StationProgressPreregisterID INT NOT NULL,
Mcu_StationProgressMcuID INT NOT NULL,
Mcu_StationProgressStationID INT NOT NULL, -- T_SampleStationID
Mcu_StationProgressStationName VARCHAR(100), -- T_SampleStationName
Mcu_StationProgressSource VARCHAR(10), -- 'lab' | 'nonlab'
Mcu_StationProgressCheckinDate DATE, -- tanggal checkin pasien
Mcu_StationProgressSamplingAt DATETIME, -- lab: SamplingDate+Time
Mcu_StationProgressReceiveAt DATETIME, -- lab: ReceiveDate+Time
Mcu_StationProgressProcessAt DATETIME, -- lab: ProcessingDate+Time
Mcu_StationProgressDoneAt DATETIME, -- lab: DoneDate+Time | nonlab: result entry time
Mcu_StationProgressSyncedAt DATETIME,
INDEX idx_order_id (Mcu_StationProgressOrderID),
INDEX idx_preregister_id (Mcu_StationProgressPreregisterID),
INDEX idx_mcu_id (Mcu_StationProgressMcuID),
INDEX idx_station_id (Mcu_StationProgressStationID),
INDEX idx_checkin_date (Mcu_StationProgressCheckinDate)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -0,0 +1,250 @@
-- Migration 002: stored procedure untuk generate dashboard data dari project cpone yang sudah ada
-- Digunakan untuk testing dengan data historis (contoh: Mgm_McuID = 99)
-- Tambah unique key di mcu_patient_schedule agar ON DUPLICATE KEY UPDATE bisa berjalan
ALTER TABLE mcu_patient_schedule
ADD UNIQUE KEY uq_preregister_schedule (Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate);
ALTER TABLE mcu_checkinout
DROP INDEX uq_checkinout_id,
ADD UNIQUE KEY uq_checkinout_segment (
Mcu_CheckinoutPreregisterID,
Mcu_CheckinoutDate,
Mcu_CheckinoutInTime
);
-- ============================================================
-- sp_generate_dashboard_data
-- Parameter: p_mcu_id INT (Mgm_McuID dari cpone)
-- Mengisi: mcu_project, mcu_patient, mcu_patient_schedule, mcu_checkinout
-- ============================================================
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
DELIMITER $$
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
BEGIN
-- ----------------------------------------------------------
-- 1. mcu_project
-- Source: cpone.mgm_mcu JOIN cpone.corporate
-- ----------------------------------------------------------
INSERT INTO mcu_project (
Mcu_ProjectMcuID,
Mcu_ProjectCorporateID,
Mcu_ProjectCorporateName,
Mcu_ProjectNumber,
Mcu_ProjectLabel,
Mcu_ProjectBranchID,
Mcu_ProjectStartDate,
Mcu_ProjectEndDate,
Mcu_ProjectIsActive,
Mcu_ProjectTotalParticipant,
Mcu_ProjectSyncedAt
)
SELECT
m.Mgm_McuID,
m.Mgm_McuCorporateID,
c.CorporateName,
m.Mgm_McuNumber,
m.Mgm_McuLabel,
m.Mgm_McuM_BranchID,
m.Mgm_McuStartDate,
m.Mgm_McuEndDate,
m.Mgm_McuIsActive,
m.Mgm_McuTotalParticipant,
NOW()
FROM cpone.mgm_mcu m
LEFT JOIN cpone.corporate c ON c.CorporateID = m.Mgm_McuCorporateID
WHERE m.Mgm_McuID = p_mcu_id
ON DUPLICATE KEY UPDATE
Mcu_ProjectCorporateName = VALUES(Mcu_ProjectCorporateName),
Mcu_ProjectLabel = VALUES(Mcu_ProjectLabel),
Mcu_ProjectIsActive = VALUES(Mcu_ProjectIsActive),
Mcu_ProjectTotalParticipant = VALUES(Mcu_ProjectTotalParticipant),
Mcu_ProjectSyncedAt = NOW();
-- ----------------------------------------------------------
-- 2. mcu_patient
-- Source: cpone.mcu_preregister_patients
-- ----------------------------------------------------------
INSERT INTO mcu_patient (
Mcu_PatientPreregisterID,
Mcu_PatientMcuID,
Mcu_PatientName,
Mcu_PatientNIP,
Mcu_PatientGender,
Mcu_PatientDOB,
Mcu_PatientDepartment,
Mcu_PatientDivision,
Mcu_PatientPosisi,
Mcu_PatientIsRegistered,
Mcu_PatientOrderID,
Mcu_PatientIsActive,
Mcu_PatientSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID,
p.Mcu_PreregisterPatientsMgm_McuID,
p.Mcu_PreregisterPatientsPatientName,
p.Mcu_PreregisterPatientsNIP,
p.Mcu_PreregisterPatientsGender,
p.Mcu_PreregisterPatientsDOB,
p.Mcu_PreregisterPatientsDepartment,
p.Mcu_PreregisterPatientsDivisi,
p.Mcu_PreregisterPatientsPosisi,
p.Mcu_PreregisterPatientsIsRegistered,
p.Mcu_PreregisterPatientsT_OrderHeaderID,
p.Mcu_PreregisterPatientsIsActive,
NOW()
FROM cpone.mcu_preregister_patients p
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientName = VALUES(Mcu_PatientName),
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
Mcu_PatientSyncedAt = NOW();
-- ----------------------------------------------------------
-- 3. mcu_patient_schedule
-- Source: T_OrderHeaderDate sebagai jadwal checkin
-- Hanya untuk pasien yang sudah punya order
-- ----------------------------------------------------------
INSERT INTO mcu_patient_schedule (
Mcu_PatientSchedulePreregisterID,
Mcu_PatientScheduleDate,
Mcu_PatientScheduleIsActive,
Mcu_PatientScheduleSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID,
DATE(o.T_OrderHeaderDate),
'Y',
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o
ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientScheduleSyncedAt = NOW();
-- ----------------------------------------------------------
-- 4. mcu_checkinout
-- Satu baris per tanggal proses.
-- Check-in : tanggal header untuk hari pertama, atau event paling awal untuk hari lanjutan.
-- Check-out : event paling akhir pada tanggal itu.
-- ----------------------------------------------------------
INSERT INTO mcu_checkinout (
Mcu_CheckinoutCheckinoutID,
Mcu_CheckinoutPreregisterID,
Mcu_CheckinoutOrderID,
Mcu_CheckinoutDate,
Mcu_CheckinoutInTime,
Mcu_CheckinoutOutTime,
Mcu_CheckinoutNextDate,
Mcu_CheckinoutIsActive,
Mcu_CheckinoutSyncedAt
)
SELECT
src.order_id,
src.preregister_id,
src.order_id,
src.segment_date,
TIME(src.checkin_dt),
TIME(src.checkout_dt),
CASE
WHEN DATE(src.checkout_dt) = src.segment_date THEN NULL
ELSE DATE(src.checkout_dt)
END,
'Y',
NOW()
FROM (
SELECT
o.T_OrderHeaderID AS order_id,
p.Mcu_PreregisterPatientsID AS preregister_id,
o.T_OrderHeaderDate AS header_dt,
ev.segment_date,
CASE
WHEN ev.segment_date = DATE(o.T_OrderHeaderDate) THEN o.T_OrderHeaderDate
ELSE MIN(ev.event_dt)
END AS checkin_dt,
MAX(ev.event_dt) AS checkout_dt
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o
ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN (
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(o2.T_OrderHeaderDate) AS segment_date,
o2.T_OrderHeaderDate AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2
ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
UNION ALL
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)) AS segment_date,
TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime) AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2
ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s2
ON s2.T_OrderSampleT_OrderHeaderID = o2.T_OrderHeaderID
AND s2.T_OrderSampleIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND s2.T_OrderSampleReceiveDate IS NOT NULL
AND s2.T_OrderSampleReceiveTime IS NOT NULL
UNION ALL
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)) AS segment_date,
TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime) AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2
ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts2
ON ts2.T_SamplingSoT_OrderHeaderID = o2.T_OrderHeaderID
AND ts2.T_SamplingSoIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND ts2.T_SamplingSoDoneDate IS NOT NULL
AND ts2.T_SamplingSoDoneTime IS NOT NULL
) ev
ON ev.preregister_id = p.Mcu_PreregisterPatientsID
AND ev.order_id = o.T_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY
o.T_OrderHeaderID,
p.Mcu_PreregisterPatientsID,
o.T_OrderHeaderDate,
ev.segment_date
) AS src
ON DUPLICATE KEY UPDATE
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
Mcu_CheckinoutSyncedAt = NOW();
END $$
DELIMITER ;

View File

@@ -0,0 +1,269 @@
-- Migration 003: tambah unique key mcu_station_progress + update SP dengan station progress
ALTER TABLE mcu_station_progress
ADD UNIQUE KEY uq_order_station_source (
Mcu_StationProgressOrderID,
Mcu_StationProgressStationID,
Mcu_StationProgressSource
);
ALTER TABLE mcu_checkinout
DROP INDEX uq_checkinout_id,
ADD UNIQUE KEY uq_checkinout_segment (
Mcu_CheckinoutPreregisterID,
Mcu_CheckinoutDate,
Mcu_CheckinoutInTime
);
-- Update SP: tambah step 5 untuk mcu_station_progress
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
DELIMITER $$
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
BEGIN
-- ----------------------------------------------------------
-- 1. mcu_project
-- ----------------------------------------------------------
INSERT INTO mcu_project (
Mcu_ProjectMcuID, Mcu_ProjectCorporateID, Mcu_ProjectCorporateName,
Mcu_ProjectNumber, Mcu_ProjectLabel, Mcu_ProjectBranchID,
Mcu_ProjectStartDate, Mcu_ProjectEndDate, Mcu_ProjectIsActive,
Mcu_ProjectTotalParticipant, Mcu_ProjectSyncedAt
)
SELECT
m.Mgm_McuID, m.Mgm_McuCorporateID, c.CorporateName,
m.Mgm_McuNumber, m.Mgm_McuLabel, m.Mgm_McuM_BranchID,
m.Mgm_McuStartDate, m.Mgm_McuEndDate, m.Mgm_McuIsActive,
m.Mgm_McuTotalParticipant, NOW()
FROM cpone.mgm_mcu m
LEFT JOIN cpone.corporate c ON c.CorporateID = m.Mgm_McuCorporateID
WHERE m.Mgm_McuID = p_mcu_id
ON DUPLICATE KEY UPDATE
Mcu_ProjectCorporateName = VALUES(Mcu_ProjectCorporateName),
Mcu_ProjectLabel = VALUES(Mcu_ProjectLabel),
Mcu_ProjectIsActive = VALUES(Mcu_ProjectIsActive),
Mcu_ProjectTotalParticipant = VALUES(Mcu_ProjectTotalParticipant),
Mcu_ProjectSyncedAt = NOW();
-- ----------------------------------------------------------
-- 2. mcu_patient
-- ----------------------------------------------------------
INSERT INTO mcu_patient (
Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName,
Mcu_PatientNIP, Mcu_PatientGender, Mcu_PatientDOB,
Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi,
Mcu_PatientIsRegistered, Mcu_PatientOrderID, Mcu_PatientIsActive,
Mcu_PatientSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, p.Mcu_PreregisterPatientsMgm_McuID,
p.Mcu_PreregisterPatientsPatientName, p.Mcu_PreregisterPatientsNIP,
p.Mcu_PreregisterPatientsGender, p.Mcu_PreregisterPatientsDOB,
p.Mcu_PreregisterPatientsDepartment, p.Mcu_PreregisterPatientsDivisi,
p.Mcu_PreregisterPatientsPosisi,
p.Mcu_PreregisterPatientsIsRegistered, p.Mcu_PreregisterPatientsT_OrderHeaderID,
p.Mcu_PreregisterPatientsIsActive, NOW()
FROM cpone.mcu_preregister_patients p
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientName = VALUES(Mcu_PatientName),
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
Mcu_PatientSyncedAt = NOW();
-- ----------------------------------------------------------
-- 3. mcu_patient_schedule
-- ----------------------------------------------------------
INSERT INTO mcu_patient_schedule (
Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate,
Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, DATE(o.T_OrderHeaderDate), 'Y', NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientScheduleSyncedAt = NOW();
-- ----------------------------------------------------------
-- 4. mcu_checkinout
-- Satu baris per tanggal proses.
-- ----------------------------------------------------------
INSERT INTO mcu_checkinout (
Mcu_CheckinoutCheckinoutID, Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
Mcu_CheckinoutNextDate, Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt
)
SELECT
src.order_id,
src.preregister_id,
src.order_id,
src.segment_date,
TIME(src.checkin_dt),
TIME(src.checkout_dt),
CASE
WHEN DATE(src.checkout_dt) = src.segment_date THEN NULL
ELSE DATE(src.checkout_dt)
END,
'Y', NOW()
FROM (
SELECT
o.T_OrderHeaderID AS order_id,
p.Mcu_PreregisterPatientsID AS preregister_id,
o.T_OrderHeaderDate AS header_dt,
ev.segment_date,
CASE
WHEN ev.segment_date = DATE(o.T_OrderHeaderDate) THEN o.T_OrderHeaderDate
ELSE MIN(ev.event_dt)
END AS checkin_dt,
MAX(ev.event_dt) AS checkout_dt
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN (
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(o2.T_OrderHeaderDate) AS segment_date,
o2.T_OrderHeaderDate AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
UNION ALL
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)) AS segment_date,
TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime) AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s2
ON s2.T_OrderSampleT_OrderHeaderID = o2.T_OrderHeaderID
AND s2.T_OrderSampleIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND s2.T_OrderSampleReceiveDate IS NOT NULL
AND s2.T_OrderSampleReceiveTime IS NOT NULL
UNION ALL
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)) AS segment_date,
TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime) AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts2
ON ts2.T_SamplingSoT_OrderHeaderID = o2.T_OrderHeaderID
AND ts2.T_SamplingSoIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND ts2.T_SamplingSoDoneDate IS NOT NULL
AND ts2.T_SamplingSoDoneTime IS NOT NULL
) ev
ON ev.preregister_id = p.Mcu_PreregisterPatientsID
AND ev.order_id = o.T_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, o.T_OrderHeaderDate, ev.segment_date
) AS src
ON DUPLICATE KEY UPDATE
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
Mcu_CheckinoutSyncedAt = NOW();
-- ----------------------------------------------------------
-- 5. mcu_station_progress — lab (dari t_ordersample)
-- GROUP BY order+station supaya satu baris per pasien per station
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID,
p.Mcu_PreregisterPatientsID,
p_mcu_id,
s.T_OrderSampleT_SampleStationID,
ss.T_SampleStationName,
'lab',
DATE(o.T_OrderHeaderDate),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleReceiveDate, s.T_OrderSampleReceiveTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleProcessingDate, s.T_OrderSampleProcessingTime)), '0000-00-00 00:00:00'),
NULL,
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s ON s.T_OrderSampleT_OrderHeaderID = o.T_OrderHeaderID AND s.T_OrderSampleIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = s.T_OrderSampleT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressSamplingAt = VALUES(Mcu_StationProgressSamplingAt),
Mcu_StationProgressReceiveAt = VALUES(Mcu_StationProgressReceiveAt),
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressSyncedAt = NOW();
-- ----------------------------------------------------------
-- 6. mcu_station_progress — nonlab (dari t_samplingso)
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID,
p.Mcu_PreregisterPatientsID,
p_mcu_id,
ts.T_SamplingSoT_SampleStationID,
ss.T_SampleStationName,
'nonlab',
DATE(o.T_OrderHeaderDate),
NULL,
NULL,
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoDoneDate, ts.T_SamplingSoDoneTime)), '0000-00-00 00:00:00'),
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts ON ts.T_SamplingSoT_OrderHeaderID = o.T_OrderHeaderID AND ts.T_SamplingSoIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = ts.T_SamplingSoT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressDoneAt = VALUES(Mcu_StationProgressDoneAt),
Mcu_StationProgressSyncedAt = NOW();
END $$
DELIMITER ;

View File

@@ -0,0 +1,17 @@
-- Migration 004: tabel user untuk login dashboard
CREATE TABLE IF NOT EXISTS dashboard_user (
User_ID INT AUTO_INCREMENT PRIMARY KEY,
User_Username VARCHAR(50) NOT NULL,
User_Password VARCHAR(255) NOT NULL, -- bcrypt hash
User_DisplayName VARCHAR(100),
User_IsActive CHAR(1) DEFAULT 'Y',
User_CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
User_UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_username (User_Username),
INDEX idx_is_active (User_IsActive)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Default user: admin / admin123 (ganti password setelah pertama login)
INSERT IGNORE INTO dashboard_user (User_Username, User_Password, User_DisplayName)
VALUES ('admin', '$2a$10$dArL7F97pp/JdtIYhY/xD.W5RqAgKiW/ZvJoY75b6oer7xrQx1UNC', 'Administrator');

View File

@@ -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');

View File

@@ -0,0 +1,100 @@
-- Migration 006: mapping user ke mcu_project (many-to-many)
CREATE TABLE IF NOT EXISTS dashboard_user_project (
UserProj_ID INT AUTO_INCREMENT PRIMARY KEY,
UserProj_UserID INT NOT NULL, -- dashboard_user.User_ID
UserProj_McuID INT NOT NULL, -- mcu_project.Mcu_ProjectMcuID
UserProj_IsActive CHAR(1) DEFAULT 'Y',
UserProj_CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
UserProj_UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_mcu (UserProj_UserID, UserProj_McuID),
INDEX idx_user_id (UserProj_UserID),
INDEX idx_mcu_id (UserProj_McuID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- sp_assign_user_project
-- Assign satu mcu_project ke satu user (idempotent).
-- Parameter:
-- p_username VARCHAR(50) -- username di dashboard_user
-- p_mcu_id INT -- Mcu_ProjectMcuID di mcu_project
-- ============================================================
DROP PROCEDURE IF EXISTS sp_assign_user_project;
DELIMITER $$
CREATE PROCEDURE sp_assign_user_project(
IN p_username VARCHAR(50),
IN p_mcu_id INT
)
BEGIN
DECLARE v_user_id INT;
-- Cari user
SELECT User_ID INTO v_user_id
FROM dashboard_user
WHERE User_Username = p_username AND User_IsActive = 'Y'
LIMIT 1;
IF v_user_id IS NULL THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'User tidak ditemukan atau tidak aktif';
END IF;
-- Validasi mcu_project ada
IF NOT EXISTS (
SELECT 1 FROM mcu_project WHERE Mcu_ProjectMcuID = p_mcu_id
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'mcu_id tidak ditemukan di mcu_project';
END IF;
INSERT INTO dashboard_user_project (UserProj_UserID, UserProj_McuID, UserProj_IsActive)
VALUES (v_user_id, p_mcu_id, 'Y')
ON DUPLICATE KEY UPDATE
UserProj_IsActive = 'Y',
UserProj_UpdatedAt = NOW();
END $$
DELIMITER ;
-- ============================================================
-- sp_remove_user_project
-- Cabut akses satu mcu_project dari user (soft delete).
-- Parameter:
-- p_username VARCHAR(50)
-- p_mcu_id INT
-- ============================================================
DROP PROCEDURE IF EXISTS sp_remove_user_project;
DELIMITER $$
CREATE PROCEDURE sp_remove_user_project(
IN p_username VARCHAR(50),
IN p_mcu_id INT
)
BEGIN
DECLARE v_user_id INT;
SELECT User_ID INTO v_user_id
FROM dashboard_user
WHERE User_Username = p_username
LIMIT 1;
IF v_user_id IS NULL THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'User tidak ditemukan';
END IF;
UPDATE dashboard_user_project
SET UserProj_IsActive = 'N',
UserProj_UpdatedAt = NOW()
WHERE UserProj_UserID = v_user_id
AND UserProj_McuID = p_mcu_id;
END $$
DELIMITER ;

View File

@@ -0,0 +1,108 @@
-- Migration 007: pisah sp_upsert_dashboard_user menjadi insert dan update terpisah
DROP PROCEDURE IF EXISTS sp_upsert_dashboard_user;
-- ============================================================
-- sp_insert_dashboard_user
-- Hanya untuk membuat user BARU.
-- Error jika username sudah terdaftar.
-- Parameter:
-- p_username VARCHAR(50)
-- p_password VARCHAR(255) plain text, minimal 6 karakter
-- p_display_name VARCHAR(100) boleh NULL
-- ============================================================
DROP PROCEDURE IF EXISTS sp_insert_dashboard_user;
DELIMITER $$
CREATE PROCEDURE sp_insert_dashboard_user(
IN p_username VARCHAR(50),
IN p_password VARCHAR(255),
IN p_display_name VARCHAR(100)
)
BEGIN
DECLARE v_salt VARCHAR(36);
DECLARE v_hash VARCHAR(64);
IF p_username IS NULL OR TRIM(p_username) = '' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Username tidak boleh kosong';
END IF;
IF p_password IS NULL OR TRIM(p_password) = '' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Password tidak boleh kosong';
END IF;
IF LENGTH(p_password) < 6 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Password minimal 6 karakter';
END IF;
IF EXISTS (SELECT 1 FROM dashboard_user WHERE User_Username = TRIM(p_username)) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Username sudah terdaftar';
END IF;
SET v_salt = UUID();
SET v_hash = SHA2(CONCAT(v_salt, ':', p_password), 256);
INSERT INTO dashboard_user (User_Username, User_Password, User_Salt, User_DisplayName, User_IsActive)
VALUES (TRIM(p_username), v_hash, v_salt, p_display_name, 'Y');
END $$
DELIMITER ;
-- ============================================================
-- sp_reset_dashboard_user_password
-- Hanya untuk reset password user yang SUDAH ADA.
-- Error jika username tidak ditemukan.
-- Parameter:
-- p_username VARCHAR(50)
-- p_new_password VARCHAR(255) plain text, minimal 6 karakter
-- ============================================================
DROP PROCEDURE IF EXISTS sp_reset_dashboard_user_password;
DELIMITER $$
CREATE PROCEDURE sp_reset_dashboard_user_password(
IN p_username VARCHAR(50),
IN p_new_password VARCHAR(255)
)
BEGIN
DECLARE v_salt VARCHAR(36);
DECLARE v_hash VARCHAR(64);
IF p_new_password IS NULL OR TRIM(p_new_password) = '' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Password tidak boleh kosong';
END IF;
IF LENGTH(p_new_password) < 6 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Password minimal 6 karakter';
END IF;
IF NOT EXISTS (SELECT 1 FROM dashboard_user WHERE User_Username = TRIM(p_username)) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'User tidak ditemukan';
END IF;
SET v_salt = UUID();
SET v_hash = SHA2(CONCAT(v_salt, ':', p_new_password), 256);
UPDATE dashboard_user
SET User_Password = v_hash,
User_Salt = v_salt,
User_UpdatedAt = NOW()
WHERE User_Username = TRIM(p_username);
END $$
DELIMITER ;
-- Tidak perlu re-seed, user admin sudah ada dari migration 005

View File

@@ -0,0 +1,264 @@
-- Migration 008: fix Mcu_StationProgressCheckinDate di sp_generate_dashboard_data
-- Sebelumnya pakai DATE(T_OrderHeaderDate) — tanggal order, bukan tanggal station dikerjakan.
-- Seharusnya:
-- lab → DATE(SamplingAt), fallback T_OrderHeaderDate
-- nonlab → DATE(ProcessAt), fallback T_OrderHeaderDate
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
DELIMITER $$
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
BEGIN
-- ----------------------------------------------------------
-- 1. mcu_project
-- ----------------------------------------------------------
INSERT INTO mcu_project (
Mcu_ProjectMcuID, Mcu_ProjectCorporateID, Mcu_ProjectCorporateName,
Mcu_ProjectNumber, Mcu_ProjectLabel, Mcu_ProjectBranchID,
Mcu_ProjectStartDate, Mcu_ProjectEndDate, Mcu_ProjectIsActive,
Mcu_ProjectTotalParticipant, Mcu_ProjectSyncedAt
)
SELECT
m.Mgm_McuID, m.Mgm_McuCorporateID, c.CorporateName,
m.Mgm_McuNumber, m.Mgm_McuLabel, m.Mgm_McuM_BranchID,
m.Mgm_McuStartDate, m.Mgm_McuEndDate, m.Mgm_McuIsActive,
m.Mgm_McuTotalParticipant, NOW()
FROM cpone.mgm_mcu m
LEFT JOIN cpone.corporate c ON c.CorporateID = m.Mgm_McuCorporateID
WHERE m.Mgm_McuID = p_mcu_id
ON DUPLICATE KEY UPDATE
Mcu_ProjectCorporateName = VALUES(Mcu_ProjectCorporateName),
Mcu_ProjectLabel = VALUES(Mcu_ProjectLabel),
Mcu_ProjectIsActive = VALUES(Mcu_ProjectIsActive),
Mcu_ProjectTotalParticipant = VALUES(Mcu_ProjectTotalParticipant),
Mcu_ProjectSyncedAt = NOW();
-- ----------------------------------------------------------
-- 2. mcu_patient
-- ----------------------------------------------------------
INSERT INTO mcu_patient (
Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName,
Mcu_PatientNIP, Mcu_PatientGender, Mcu_PatientDOB,
Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi,
Mcu_PatientIsRegistered, Mcu_PatientOrderID, Mcu_PatientIsActive,
Mcu_PatientSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, p.Mcu_PreregisterPatientsMgm_McuID,
p.Mcu_PreregisterPatientsPatientName, p.Mcu_PreregisterPatientsNIP,
p.Mcu_PreregisterPatientsGender, p.Mcu_PreregisterPatientsDOB,
p.Mcu_PreregisterPatientsDepartment, p.Mcu_PreregisterPatientsDivisi,
p.Mcu_PreregisterPatientsPosisi,
p.Mcu_PreregisterPatientsIsRegistered, p.Mcu_PreregisterPatientsT_OrderHeaderID,
p.Mcu_PreregisterPatientsIsActive, NOW()
FROM cpone.mcu_preregister_patients p
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientName = VALUES(Mcu_PatientName),
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
Mcu_PatientSyncedAt = NOW();
-- ----------------------------------------------------------
-- 3. mcu_patient_schedule
-- ----------------------------------------------------------
INSERT INTO mcu_patient_schedule (
Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate,
Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, DATE(o.T_OrderHeaderDate), 'Y', NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientScheduleSyncedAt = NOW();
-- ----------------------------------------------------------
-- 4. mcu_checkinout
-- ----------------------------------------------------------
INSERT INTO mcu_checkinout (
Mcu_CheckinoutMcuID, Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
Mcu_CheckinoutNextDate, Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt
)
SELECT
p_mcu_id,
src.preregister_id,
src.order_id,
src.segment_date,
TIME(src.checkin_dt),
TIME(src.checkout_dt),
CASE
WHEN DATE(src.checkout_dt) = src.segment_date THEN NULL
ELSE DATE(src.checkout_dt)
END,
'Y', NOW()
FROM (
SELECT
o.T_OrderHeaderID AS order_id,
p.Mcu_PreregisterPatientsID AS preregister_id,
o.T_OrderHeaderDate AS header_dt,
ev.segment_date,
CASE
WHEN ev.segment_date = DATE(o.T_OrderHeaderDate) THEN o.T_OrderHeaderDate
ELSE MIN(ev.event_dt)
END AS checkin_dt,
MAX(ev.event_dt) AS checkout_dt
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN (
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(o2.T_OrderHeaderDate) AS segment_date,
o2.T_OrderHeaderDate AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
UNION ALL
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)) AS segment_date,
TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime) AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s2
ON s2.T_OrderSampleT_OrderHeaderID = o2.T_OrderHeaderID
AND s2.T_OrderSampleIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND s2.T_OrderSampleReceiveDate IS NOT NULL
AND s2.T_OrderSampleReceiveTime IS NOT NULL
UNION ALL
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)) AS segment_date,
TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime) AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts2
ON ts2.T_SamplingSoT_OrderHeaderID = o2.T_OrderHeaderID
AND ts2.T_SamplingSoIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND ts2.T_SamplingSoDoneDate IS NOT NULL
AND ts2.T_SamplingSoDoneTime IS NOT NULL
) ev
ON ev.preregister_id = p.Mcu_PreregisterPatientsID
AND ev.order_id = o.T_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, o.T_OrderHeaderDate, ev.segment_date
) AS src
ON DUPLICATE KEY UPDATE
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
Mcu_CheckinoutSyncedAt = NOW();
-- ----------------------------------------------------------
-- 5. mcu_station_progress — lab (dari t_ordersample)
-- CheckinDate = DATE(SamplingAt), fallback DATE(T_OrderHeaderDate)
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID,
p.Mcu_PreregisterPatientsID,
p_mcu_id,
s.T_OrderSampleT_SampleStationID,
ss.T_SampleStationName,
'lab',
COALESCE(
DATE(NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00')),
DATE(o.T_OrderHeaderDate)
),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleReceiveDate, s.T_OrderSampleReceiveTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleProcessingDate, s.T_OrderSampleProcessingTime)), '0000-00-00 00:00:00'),
NULL,
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s ON s.T_OrderSampleT_OrderHeaderID = o.T_OrderHeaderID AND s.T_OrderSampleIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = s.T_OrderSampleT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressCheckinDate = VALUES(Mcu_StationProgressCheckinDate),
Mcu_StationProgressSamplingAt = VALUES(Mcu_StationProgressSamplingAt),
Mcu_StationProgressReceiveAt = VALUES(Mcu_StationProgressReceiveAt),
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressSyncedAt = NOW();
-- ----------------------------------------------------------
-- 6. mcu_station_progress — nonlab (dari t_samplingso)
-- CheckinDate = DATE(ProcessAt), fallback DATE(T_OrderHeaderDate)
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID,
p.Mcu_PreregisterPatientsID,
p_mcu_id,
ts.T_SamplingSoT_SampleStationID,
ss.T_SampleStationName,
'nonlab',
COALESCE(
DATE(NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00')),
DATE(o.T_OrderHeaderDate)
),
NULL,
NULL,
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoDoneDate, ts.T_SamplingSoDoneTime)), '0000-00-00 00:00:00'),
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts ON ts.T_SamplingSoT_OrderHeaderID = o.T_OrderHeaderID AND ts.T_SamplingSoIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = ts.T_SamplingSoT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressCheckinDate = VALUES(Mcu_StationProgressCheckinDate),
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressDoneAt = VALUES(Mcu_StationProgressDoneAt),
Mcu_StationProgressSyncedAt = NOW();
END $$
DELIMITER ;

View File

@@ -0,0 +1,13 @@
-- Migration 009: ganti Mcu_CheckinoutCheckinoutID dengan Mcu_CheckinoutMcuID
-- Kolom lama (CheckinoutCheckinoutID) tidak dipakai di aplikasi dan di SP
-- diisi dengan order_id yang salah. Ganti dengan mcu_id supaya query lebih simpel.
ALTER TABLE mcu_checkinout
DROP COLUMN Mcu_CheckinoutCheckinoutID,
ADD COLUMN Mcu_CheckinoutMcuID INT NOT NULL DEFAULT 0 AFTER Mcu_CheckinoutID,
ADD INDEX idx_mcu_id (Mcu_CheckinoutMcuID);
-- Isi Mcu_CheckinoutMcuID dari mcu_patient untuk data yang sudah ada
UPDATE mcu_checkinout mc
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = mc.Mcu_CheckinoutPreregisterID
SET mc.Mcu_CheckinoutMcuID = mp.Mcu_PatientMcuID;

View File

@@ -0,0 +1,259 @@
-- Migration 010: Add age column to mcu_patient and update SP
ALTER TABLE mcu_patient
ADD COLUMN Mcu_PatientAge INT DEFAULT NULL AFTER Mcu_PatientDOB;
-- Update SP: tambah age dari T_OrderHeader.M_PatientAge
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
DELIMITER $$
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
BEGIN
-- ----------------------------------------------------------
-- 1. mcu_project
-- ----------------------------------------------------------
INSERT INTO mcu_project (
Mcu_ProjectMcuID, Mcu_ProjectCorporateID, Mcu_ProjectCorporateName,
Mcu_ProjectNumber, Mcu_ProjectLabel, Mcu_ProjectBranchID,
Mcu_ProjectStartDate, Mcu_ProjectEndDate, Mcu_ProjectIsActive,
Mcu_ProjectTotalParticipant, Mcu_ProjectSyncedAt
)
SELECT
m.Mgm_McuID, m.Mgm_McuCorporateID, c.CorporateName,
m.Mgm_McuNumber, m.Mgm_McuLabel, m.Mgm_McuM_BranchID,
m.Mgm_McuStartDate, m.Mgm_McuEndDate, m.Mgm_McuIsActive,
m.Mgm_McuTotalParticipant, NOW()
FROM cpone.mgm_mcu m
LEFT JOIN cpone.corporate c ON c.CorporateID = m.Mgm_McuCorporateID
WHERE m.Mgm_McuID = p_mcu_id
ON DUPLICATE KEY UPDATE
Mcu_ProjectCorporateName = VALUES(Mcu_ProjectCorporateName),
Mcu_ProjectLabel = VALUES(Mcu_ProjectLabel),
Mcu_ProjectIsActive = VALUES(Mcu_ProjectIsActive),
Mcu_ProjectTotalParticipant = VALUES(Mcu_ProjectTotalParticipant),
Mcu_ProjectSyncedAt = NOW();
-- ----------------------------------------------------------
-- 2. mcu_patient (with age)
-- ----------------------------------------------------------
INSERT INTO mcu_patient (
Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName,
Mcu_PatientNIP, Mcu_PatientGender, Mcu_PatientDOB, Mcu_PatientAge,
Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi,
Mcu_PatientIsRegistered, Mcu_PatientOrderID, Mcu_PatientIsActive,
Mcu_PatientSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, p.Mcu_PreregisterPatientsMgm_McuID,
p.Mcu_PreregisterPatientsPatientName, p.Mcu_PreregisterPatientsNIP,
p.Mcu_PreregisterPatientsGender, p.Mcu_PreregisterPatientsDOB,
o.T_OrderHeaderM_PatientAge,
p.Mcu_PreregisterPatientsDepartment, p.Mcu_PreregisterPatientsDivisi,
p.Mcu_PreregisterPatientsPosisi,
p.Mcu_PreregisterPatientsIsRegistered, p.Mcu_PreregisterPatientsT_OrderHeaderID,
p.Mcu_PreregisterPatientsIsActive, NOW()
FROM cpone.mcu_preregister_patients p
LEFT JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientName = VALUES(Mcu_PatientName),
Mcu_PatientAge = VALUES(Mcu_PatientAge),
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
Mcu_PatientSyncedAt = NOW();
-- ----------------------------------------------------------
-- 3. mcu_patient_schedule
-- ----------------------------------------------------------
INSERT INTO mcu_patient_schedule (
Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate,
Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, DATE(o.T_OrderHeaderDate), 'Y', NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientScheduleSyncedAt = NOW();
-- ----------------------------------------------------------
-- 4. mcu_checkinout
-- Satu baris per tanggal proses.
-- ----------------------------------------------------------
INSERT INTO mcu_checkinout (
Mcu_CheckinoutCheckinoutID, Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
Mcu_CheckinoutNextDate, Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt, Mcu_CheckinoutMcuID
)
SELECT
src.order_id,
src.preregister_id,
src.order_id,
src.segment_date,
TIME(src.checkin_dt),
TIME(src.checkout_dt),
CASE
WHEN DATE(src.checkout_dt) = src.segment_date THEN NULL
ELSE DATE(src.checkout_dt)
END,
'Y', NOW(), p_mcu_id
FROM (
SELECT
o.T_OrderHeaderID AS order_id,
p.Mcu_PreregisterPatientsID AS preregister_id,
o.T_OrderHeaderDate AS header_dt,
ev.segment_date,
CASE
WHEN ev.segment_date = DATE(o.T_OrderHeaderDate) THEN o.T_OrderHeaderDate
ELSE MIN(ev.event_dt)
END AS checkin_dt,
MAX(ev.event_dt) AS checkout_dt
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN (
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(o2.T_OrderHeaderDate) AS segment_date,
o2.T_OrderHeaderDate AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
UNION ALL
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)) AS segment_date,
TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime) AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s2
ON s2.T_OrderSampleT_OrderHeaderID = o2.T_OrderHeaderID
AND s2.T_OrderSampleIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND s2.T_OrderSampleReceiveDate IS NOT NULL
AND s2.T_OrderSampleReceiveTime IS NOT NULL
UNION ALL
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)) AS segment_date,
TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime) AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts2
ON ts2.T_SamplingSoT_OrderHeaderID = o2.T_OrderHeaderID
AND ts2.T_SamplingSoIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND ts2.T_SamplingSoDoneDate IS NOT NULL
AND ts2.T_SamplingSoDoneTime IS NOT NULL
) ev
ON ev.preregister_id = p.Mcu_PreregisterPatientsID
AND ev.order_id = o.T_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, o.T_OrderHeaderDate, ev.segment_date
) AS src
ON DUPLICATE KEY UPDATE
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
Mcu_CheckinoutSyncedAt = NOW();
-- ----------------------------------------------------------
-- 5. mcu_station_progress — lab (dari t_ordersample)
-- GROUP BY order+station supaya satu baris per pasien per station
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID,
p.Mcu_PreregisterPatientsID,
p_mcu_id,
s.T_OrderSampleT_SampleStationID,
ss.T_SampleStationName,
'lab',
DATE(o.T_OrderHeaderDate),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleReceiveDate, s.T_OrderSampleReceiveTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleProcessingDate, s.T_OrderSampleProcessingTime)), '0000-00-00 00:00:00'),
NULL,
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s ON s.T_OrderSampleT_OrderHeaderID = o.T_OrderHeaderID AND s.T_OrderSampleIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = s.T_OrderSampleT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressSamplingAt = VALUES(Mcu_StationProgressSamplingAt),
Mcu_StationProgressReceiveAt = VALUES(Mcu_StationProgressReceiveAt),
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressSyncedAt = NOW();
-- ----------------------------------------------------------
-- 6. mcu_station_progress — nonlab (dari t_samplingso)
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID,
p.Mcu_PreregisterPatientsID,
p_mcu_id,
ts.T_SamplingSoT_SampleStationID,
ss.T_SampleStationName,
'nonlab',
DATE(o.T_OrderHeaderDate),
NULL,
NULL,
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoDoneDate, ts.T_SamplingSoDoneTime)), '0000-00-00 00:00:00'),
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts ON ts.T_SamplingSoT_OrderHeaderID = o.T_OrderHeaderID AND ts.T_SamplingSoIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = ts.T_SamplingSoT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressDoneAt = VALUES(Mcu_StationProgressDoneAt),
Mcu_StationProgressSyncedAt = NOW();
END $$
DELIMITER ;

View File

@@ -0,0 +1,274 @@
-- Migration 011: Add mcu_patient_resume_status table
CREATE TABLE IF NOT EXISTS mcu_patient_resume_status (
Mcu_PatientResumeStatusID INT AUTO_INCREMENT PRIMARY KEY,
Mcu_PatientResumeStatusPreregisterID INT NOT NULL,
Mcu_PatientResumeStatusMcuID INT NOT NULL,
Mcu_PatientResumeStatusStatus VARCHAR(15), -- from mcu_resume.Mcu_ResumeStatus
Mcu_PatientResumeStatusValidated CHAR(1) DEFAULT 'N', -- from mcu_resume.Mcu_ResumeValidation
Mcu_PatientResumeStatusPublished CHAR(1) DEFAULT 'N', -- default N
Mcu_PatientResumeSyncedAt DATETIME,
UNIQUE KEY uq_patient_mcu (Mcu_PatientResumeStatusPreregisterID, Mcu_PatientResumeStatusMcuID),
INDEX idx_mcu_id (Mcu_PatientResumeStatusMcuID),
INDEX idx_preregister_id (Mcu_PatientResumeStatusPreregisterID),
INDEX idx_validated (Mcu_PatientResumeStatusValidated),
INDEX idx_published (Mcu_PatientResumeStatusPublished)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Update SP: tambah step 7 untuk mcu_patient_resume_status
DROP PROCEDURE IF EXISTS sp_generate_dashboard_data;
DELIMITER $$
CREATE PROCEDURE sp_generate_dashboard_data(IN p_mcu_id INT)
BEGIN
-- ----------------------------------------------------------
-- 1. mcu_patient (with age)
-- ----------------------------------------------------------
INSERT INTO mcu_patient (
Mcu_PatientPreregisterID, Mcu_PatientMcuID, Mcu_PatientName,
Mcu_PatientNIP, Mcu_PatientGender, Mcu_PatientDOB, Mcu_PatientAge,
Mcu_PatientDepartment, Mcu_PatientDivision, Mcu_PatientPosisi,
Mcu_PatientIsRegistered, Mcu_PatientOrderID, Mcu_PatientIsActive,
Mcu_PatientSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, p.Mcu_PreregisterPatientsMgm_McuID,
p.Mcu_PreregisterPatientsPatientName, p.Mcu_PreregisterPatientsNIP,
p.Mcu_PreregisterPatientsGender, p.Mcu_PreregisterPatientsDOB,
CAST(o.T_OrderHeaderM_PatientAge AS UNSIGNED),
p.Mcu_PreregisterPatientsDepartment, p.Mcu_PreregisterPatientsDivisi,
p.Mcu_PreregisterPatientsPosisi,
p.Mcu_PreregisterPatientsIsRegistered, p.Mcu_PreregisterPatientsT_OrderHeaderID,
p.Mcu_PreregisterPatientsIsActive, NOW()
FROM cpone.mcu_preregister_patients p
LEFT JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientName = VALUES(Mcu_PatientName),
Mcu_PatientAge = VALUES(Mcu_PatientAge),
Mcu_PatientIsRegistered = VALUES(Mcu_PatientIsRegistered),
Mcu_PatientOrderID = VALUES(Mcu_PatientOrderID),
Mcu_PatientSyncedAt = NOW();
-- ----------------------------------------------------------
-- 2. mcu_patient_schedule
-- ----------------------------------------------------------
INSERT INTO mcu_patient_schedule (
Mcu_PatientSchedulePreregisterID, Mcu_PatientScheduleDate,
Mcu_PatientScheduleIsActive, Mcu_PatientScheduleSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID, DATE(o.T_OrderHeaderDate), 'Y', NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientScheduleSyncedAt = NOW();
-- ----------------------------------------------------------
-- 3. mcu_checkinout
-- Satu baris per tanggal proses.
-- ----------------------------------------------------------
INSERT INTO mcu_checkinout (
Mcu_CheckinoutCheckinoutID, Mcu_CheckinoutPreregisterID, Mcu_CheckinoutOrderID,
Mcu_CheckinoutDate, Mcu_CheckinoutInTime, Mcu_CheckinoutOutTime,
Mcu_CheckinoutNextDate, Mcu_CheckinoutIsActive, Mcu_CheckinoutSyncedAt, Mcu_CheckinoutMcuID
)
SELECT
src.order_id,
src.preregister_id,
src.order_id,
src.segment_date,
TIME(src.checkin_dt),
TIME(src.checkout_dt),
CASE
WHEN DATE(src.checkout_dt) = src.segment_date THEN NULL
ELSE DATE(src.checkout_dt)
END,
'Y', NOW(), p_mcu_id
FROM (
SELECT
o.T_OrderHeaderID AS order_id,
p.Mcu_PreregisterPatientsID AS preregister_id,
o.T_OrderHeaderDate AS header_dt,
ev.segment_date,
CASE
WHEN ev.segment_date = DATE(o.T_OrderHeaderDate) THEN o.T_OrderHeaderDate
ELSE MIN(ev.event_dt)
END AS checkin_dt,
MAX(ev.event_dt) AS checkout_dt
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN (
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(o2.T_OrderHeaderDate) AS segment_date,
o2.T_OrderHeaderDate AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
UNION ALL
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime)) AS segment_date,
TIMESTAMP(s2.T_OrderSampleReceiveDate, s2.T_OrderSampleReceiveTime) AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s2
ON s2.T_OrderSampleT_OrderHeaderID = o2.T_OrderHeaderID
AND s2.T_OrderSampleIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND s2.T_OrderSampleReceiveDate IS NOT NULL
AND s2.T_OrderSampleReceiveTime IS NOT NULL
UNION ALL
SELECT
p2.Mcu_PreregisterPatientsID AS preregister_id,
o2.T_OrderHeaderID AS order_id,
o2.T_OrderHeaderDate AS checkin_dt,
DATE(TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime)) AS segment_date,
TIMESTAMP(ts2.T_SamplingSoDoneDate, ts2.T_SamplingSoDoneTime) AS event_dt
FROM cpone.mcu_preregister_patients p2
JOIN cpone.t_orderheader o2 ON o2.T_OrderHeaderID = p2.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts2
ON ts2.T_SamplingSoT_OrderHeaderID = o2.T_OrderHeaderID
AND ts2.T_SamplingSoIsActive = 'Y'
WHERE p2.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p2.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p2.Mcu_PreregisterPatientsIsActive = 'Y'
AND ts2.T_SamplingSoDoneDate IS NOT NULL
AND ts2.T_SamplingSoDoneTime IS NOT NULL
) ev
ON ev.preregister_id = p.Mcu_PreregisterPatientsID
AND ev.order_id = o.T_OrderHeaderID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, o.T_OrderHeaderDate, ev.segment_date
) AS src
ON DUPLICATE KEY UPDATE
Mcu_CheckinoutOutTime = VALUES(Mcu_CheckinoutOutTime),
Mcu_CheckinoutNextDate = VALUES(Mcu_CheckinoutNextDate),
Mcu_CheckinoutSyncedAt = NOW();
-- ----------------------------------------------------------
-- 4. mcu_station_progress — lab (dari t_ordersample)
-- GROUP BY order+station supaya satu baris per pasien per station
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID,
p.Mcu_PreregisterPatientsID,
p_mcu_id,
s.T_OrderSampleT_SampleStationID,
ss.T_SampleStationName,
'lab',
DATE(o.T_OrderHeaderDate),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleSamplingDate, s.T_OrderSampleSamplingTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleReceiveDate, s.T_OrderSampleReceiveTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(s.T_OrderSampleProcessingDate, s.T_OrderSampleProcessingTime)), '0000-00-00 00:00:00'),
NULL,
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_ordersample s ON s.T_OrderSampleT_OrderHeaderID = o.T_OrderHeaderID AND s.T_OrderSampleIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = s.T_OrderSampleT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, s.T_OrderSampleT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressSamplingAt = VALUES(Mcu_StationProgressSamplingAt),
Mcu_StationProgressReceiveAt = VALUES(Mcu_StationProgressReceiveAt),
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressSyncedAt = NOW();
-- ----------------------------------------------------------
-- 5. mcu_station_progress — nonlab (dari t_samplingso)
-- ----------------------------------------------------------
INSERT INTO mcu_station_progress (
Mcu_StationProgressOrderID, Mcu_StationProgressPreregisterID, Mcu_StationProgressMcuID,
Mcu_StationProgressStationID, Mcu_StationProgressStationName, Mcu_StationProgressSource,
Mcu_StationProgressCheckinDate,
Mcu_StationProgressSamplingAt, Mcu_StationProgressReceiveAt, Mcu_StationProgressProcessAt,
Mcu_StationProgressDoneAt, Mcu_StationProgressSyncedAt
)
SELECT
o.T_OrderHeaderID,
p.Mcu_PreregisterPatientsID,
p_mcu_id,
ts.T_SamplingSoT_SampleStationID,
ss.T_SampleStationName,
'nonlab',
DATE(o.T_OrderHeaderDate),
NULL,
NULL,
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoProcessDate, ts.T_SamplingSoProcessTime)), '0000-00-00 00:00:00'),
NULLIF(MAX(TIMESTAMP(ts.T_SamplingSoDoneDate, ts.T_SamplingSoDoneTime)), '0000-00-00 00:00:00'),
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
JOIN cpone.t_samplingso ts ON ts.T_SamplingSoT_OrderHeaderID = o.T_OrderHeaderID AND ts.T_SamplingSoIsActive = 'Y'
JOIN cpone.t_samplestation ss ON ss.T_SampleStationID = ts.T_SamplingSoT_SampleStationID
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
GROUP BY o.T_OrderHeaderID, p.Mcu_PreregisterPatientsID, ts.T_SamplingSoT_SampleStationID, ss.T_SampleStationName, o.T_OrderHeaderDate
ON DUPLICATE KEY UPDATE
Mcu_StationProgressProcessAt = VALUES(Mcu_StationProgressProcessAt),
Mcu_StationProgressDoneAt = VALUES(Mcu_StationProgressDoneAt),
Mcu_StationProgressSyncedAt = NOW();
-- ----------------------------------------------------------
-- 6. mcu_patient_resume_status (dari mcu_resume)
-- ----------------------------------------------------------
INSERT INTO mcu_patient_resume_status (
Mcu_PatientResumeStatusPreregisterID, Mcu_PatientResumeStatusMcuID,
Mcu_PatientResumeStatusStatus, Mcu_PatientResumeStatusValidated,
Mcu_PatientResumeStatusPublished, Mcu_PatientResumeSyncedAt
)
SELECT
p.Mcu_PreregisterPatientsID,
p_mcu_id,
r.Mcu_ResumeStatus,
r.Mcu_ResumeValidation,
'N',
NOW()
FROM cpone.mcu_preregister_patients p
JOIN cpone.t_orderheader o ON o.T_OrderHeaderID = p.Mcu_PreregisterPatientsT_OrderHeaderID
LEFT JOIN cpone.mcu_resume r ON r.Mcu_ResumeT_OrderHeaderID = o.T_OrderHeaderID AND r.Mcu_ResumeIsActive = 'Y'
WHERE p.Mcu_PreregisterPatientsMgm_McuID = p_mcu_id
AND p.Mcu_PreregisterPatientsT_OrderHeaderID > 0
AND p.Mcu_PreregisterPatientsIsActive = 'Y'
ON DUPLICATE KEY UPDATE
Mcu_PatientResumeStatusStatus = VALUES(Mcu_PatientResumeStatusStatus),
Mcu_PatientResumeStatusValidated = VALUES(Mcu_PatientResumeStatusValidated),
Mcu_PatientResumeSyncedAt = NOW();
END $$
DELIMITER ;

11
cpone-dashboard/go.mod Normal file
View File

@@ -0,0 +1,11 @@
module cpone-dashboard
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/go-sql-driver/mysql v1.9.3
github.com/joho/godotenv v1.5.1
)
require filippo.io/edwards25519 v1.1.0 // indirect

8
cpone-dashboard/go.sum Normal file
View File

@@ -0,0 +1,8 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=

310
cpone-dashboard/main.go Normal file
View File

@@ -0,0 +1,310 @@
package main
import (
"cpone-dashboard/config"
"cpone-dashboard/db"
"cpone-dashboard/menu/abnormal"
"cpone-dashboard/menu/arrival"
"cpone-dashboard/menu/auth"
"cpone-dashboard/menu/dashboard"
"cpone-dashboard/menu/progress"
"cpone-dashboard/menu/projects"
"cpone-dashboard/menu/result"
"embed"
"html/template"
"io/fs"
"log"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
//go:embed templates
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
func main() {
cfg := config.Load()
if err := db.Connect(cfg.DBDSN); err != nil {
log.Fatalf("db connect: %v", err)
}
defer db.DB.Close()
dashboard.SetTemplateFuncs(template.FuncMap{
"div": func(a, b int) int {
if b == 0 {
return 0
}
return a / b
},
"mod": func(a, b int) int { return a % b },
"pct": func(a, b int) float64 {
if b == 0 {
return 0
}
return float64(a) / float64(b) * 100
},
"stationShort": func(s string) string {
s = strings.TrimPrefix(s, "Sample Station ")
s = strings.TrimPrefix(s, "sample station ")
return s
},
"fmtDate": func(s string) string {
if s == "" {
return ""
}
layouts := []string{
"2006-01-02",
"2006-01-02 15:04:05",
time.RFC3339,
"02/01/2006",
"02/01/2006 15:04:05",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t.Format("02/01/2006")
}
}
if len(s) >= 10 {
return s[8:10] + "/" + s[5:7] + "/" + s[0:4]
}
return s
},
"fmtDateTime": func(dateStr, timeStr string) string {
if dateStr == "" {
return ""
}
dateLayouts := []string{
"2006-01-02",
"2006-01-02 15:04:05",
"02/01/2006",
"02/01/2006 15:04:05",
time.RFC3339,
}
var datePart time.Time
for _, layout := range dateLayouts {
if t, err := time.ParseInLocation(layout, dateStr, time.Local); err == nil {
datePart = t
break
}
}
if datePart.IsZero() {
if len(dateStr) >= 10 {
dateStr = dateStr[8:10] + "/" + dateStr[5:7] + "/" + dateStr[0:4]
}
} else {
dateStr = datePart.Format("02/01/2006")
}
if timeStr == "" {
return dateStr
}
timeLayouts := []string{"15:04:05", "15:04"}
var timePart string
for _, layout := range timeLayouts {
if t, err := time.Parse(layout, timeStr); err == nil {
timePart = t.Format("15:04:05")
break
}
}
if timePart == "" {
timePart = timeStr
}
return dateStr + " " + timePart
},
"initials": func(name string) string {
parts := strings.Fields(name)
if len(parts) == 0 {
return "?"
}
if len(parts) == 1 {
return strings.ToUpper(string([]rune(parts[0])[:1]))
}
return strings.ToUpper(string([]rune(parts[0])[:1]) + string([]rune(parts[len(parts)-1])[:1]))
},
"slice": func(args ...int) []int { return args },
"seq": func(n int) []int {
s := make([]int, n)
for i := range s {
s[i] = i
}
return s
},
})
auth.Init(&templateFS, cfg.AuthSecret)
projects.SetTemplateFS(&templateFS)
pageFuncs := template.FuncMap{
"div": func(a, b int) int {
if b == 0 {
return 0
}
return a / b
},
"mod": func(a, b int) int { return a % b },
"pct": func(a, b int) float64 {
if b == 0 {
return 0
}
return float64(a) / float64(b) * 100
},
"stationShort": func(s string) string {
s = strings.TrimPrefix(s, "Sample Station ")
s = strings.TrimPrefix(s, "sample station ")
return s
},
"fmtDate": func(s string) string {
if s == "" {
return ""
}
layouts := []string{
"2006-01-02",
"2006-01-02 15:04:05",
time.RFC3339,
"02/01/2006",
"02/01/2006 15:04:05",
}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t.Format("02/01/2006")
}
}
if len(s) >= 10 {
return s[8:10] + "/" + s[5:7] + "/" + s[0:4]
}
return s
},
"fmtDateTime": func(dateStr, timeStr string) string {
if dateStr == "" {
return ""
}
dateLayouts := []string{
"2006-01-02",
"2006-01-02 15:04:05",
"02/01/2006",
"02/01/2006 15:04:05",
time.RFC3339,
}
var datePart time.Time
for _, layout := range dateLayouts {
if t, err := time.ParseInLocation(layout, dateStr, time.Local); err == nil {
datePart = t
break
}
}
if datePart.IsZero() {
if len(dateStr) >= 10 {
dateStr = dateStr[8:10] + "/" + dateStr[5:7] + "/" + dateStr[0:4]
}
} else {
dateStr = datePart.Format("02/01/2006")
}
if timeStr == "" {
return dateStr
}
timeLayouts := []string{"15:04:05", "15:04"}
var timePart string
for _, layout := range timeLayouts {
if t, err := time.Parse(layout, timeStr); err == nil {
timePart = t.Format("15:04:05")
break
}
}
if timePart == "" {
timePart = timeStr
}
return dateStr + " " + timePart
},
"initials": func(name string) string {
parts := strings.Fields(name)
if len(parts) == 0 {
return "?"
}
if len(parts) == 1 {
return strings.ToUpper(string([]rune(parts[0])[:1]))
}
return strings.ToUpper(string([]rune(parts[0])[:1]) + string([]rune(parts[len(parts)-1])[:1]))
},
"slice": func(args ...int) []int { return args },
"seq": func(n int) []int {
s := make([]int, n)
for i := range s {
s[i] = i
}
return s
},
}
bp := cfg.BasePath // e.g. "/cpone-dashboard" or ""
pageFuncs["b"] = func(path string) string { return bp + path }
dashboard.SetTemplateFuncs(pageFuncs)
newPageTmpl := func(files ...string) *template.Template {
paths := append([]string{"templates/layout/base.html"}, files...)
return template.Must(template.New("").Funcs(pageFuncs).ParseFS(templateFS, paths...))
}
// Propagate basePath to all packages that redirect
auth.SetBasePath(bp)
dashboard.SetBasePath(bp)
arrival.SetBasePath(bp)
progress.SetBasePath(bp)
abnormal.SetBasePath(bp)
result.SetBasePath(bp)
projects.SetBasePath(bp)
// Dashboard pakai templateFS langsung (parse per-handler)
dashboard.SetTemplateFS(&templateFS)
arrival.SetTemplates(newPageTmpl("templates/arrival/index.html"))
progress.SetTemplates(newPageTmpl("templates/progress/index.html"))
abnormal.SetTemplates(newPageTmpl("templates/abnormal/index.html"))
result.SetTemplates(newPageTmpl("templates/result/index.html"))
result.SetPDFBaseURL(cfg.PDFBaseURL)
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Static files — always mounted at bp+/static/
staticSub, _ := fs.Sub(staticFS, "static")
staticPrefix := bp + "/static"
r.Handle(staticPrefix+"/*", http.StripPrefix(staticPrefix+"/", http.FileServer(http.FS(staticSub))))
registerRoutes := func(r chi.Router) {
auth.Routes(r)
r.Group(func(r chi.Router) {
r.Use(auth.Require(cfg.AuthSecret))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, bp+"/projects", http.StatusFound)
})
auth.ProtectedRoutes(r)
r.Route("/projects", func(r chi.Router) { projects.Routes(r) })
r.Route("/dashboard", func(r chi.Router) { dashboard.Routes(r) })
r.Route("/arrival", func(r chi.Router) { arrival.Routes(r) })
r.Route("/progress", func(r chi.Router) { progress.Routes(r) })
r.Route("/abnormal", func(r chi.Router) { abnormal.Routes(r) })
r.Route("/result", func(r chi.Router) { result.Routes(r) })
})
}
if bp == "" {
registerRoutes(r)
} else {
r.Route(bp, registerRoutes)
// redirect bare /cpone-dashboard → /cpone-dashboard/
r.Get(bp, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, bp+"/", http.StatusMovedPermanently)
})
}
log.Printf("server running on :%s (base path: %q)", cfg.AppPort, bp)
if err := http.ListenAndServe(":"+cfg.AppPort, r); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,125 @@
package abnormal
import (
"cpone-dashboard/menu/auth"
"cpone-dashboard/menu/projects"
"encoding/json"
"html/template"
"net/http"
)
var tmpl *template.Template
var basePath string
func SetTemplates(t *template.Template) { tmpl = t }
func SetBasePath(p string) { basePath = p }
type pageData struct {
Username string
CurrentProject projects.ProjectItem
Group string
Groups []string
Summary AbnormalSummary
StaffJSON template.JS
AgeJSON template.JS
GenderJSON template.JS
DeptJSON template.JS
}
func toJS(v any) template.JS {
b, _ := json.Marshal(v)
return template.JS(b)
}
func Index(w http.ResponseWriter, r *http.Request) {
username := auth.Username(r)
mcuID := auth.SelectedProjectID(r)
if mcuID == 0 {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
project, ok, err := projects.GetUserProject(username, mcuID)
if err != nil || !ok {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
group := r.URL.Query().Get("group")
groups, err := GetAbnormalGroups(mcuID)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
total, err := GetTotalPatients(mcuID)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
abnormalCount, err := GetAbnormalCount(mcuID, group)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
normalCount := total - abnormalCount
if normalCount < 0 {
normalCount = 0
}
rate := 0
if total > 0 {
rate = abnormalCount * 100 / total
}
ageData, err := GetAgeChartData(mcuID, group)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
genderData, err := GetGenderChartData(mcuID, group)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
deptData, err := GetDeptChartData(mcuID, group)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
t := tmpl
if t == nil {
http.Error(w, "template not ready", http.StatusInternalServerError)
return
}
if err := t.ExecuteTemplate(w, "base", pageData{
Username: username,
CurrentProject: project,
Group: group,
Groups: groups,
Summary: AbnormalSummary{
Total: total,
Abnormal: abnormalCount,
Normal: normalCount,
AbnormalRate: rate,
},
StaffJSON: toJS(map[string]any{
"normal": normalCount,
"abnormal": abnormalCount,
}),
AgeJSON: toJS(map[string]any{
"labels": ageData.Labels,
"abnormal": ageData.Abnormal,
}),
GenderJSON: toJS(map[string]any{
"labels": genderData.Labels,
"abnormal": genderData.Abnormal,
}),
DeptJSON: toJS(map[string]any{
"labels": deptData.Labels,
"abnormal": deptData.Abnormal,
}),
}); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,257 @@
package abnormal
import (
"cpone-dashboard/db"
)
type AbnormalSummary struct {
Total int
Abnormal int
Normal int
AbnormalRate int // percentage, 0-100
}
type AgeChartData struct {
Labels []string
Abnormal []int
}
type GenderChartData struct {
Labels []string
Abnormal []int
}
type DeptChartData struct {
Labels []string
Abnormal []int
}
// GetTotalPatients returns total active patients for the project.
func GetTotalPatients(mcuID int) (int, error) {
var total int
err := db.DB.QueryRow(`
SELECT COUNT(*)
FROM mcu_patient
WHERE Mcu_PatientMcuID = ?
AND Mcu_PatientIsActive = 'Y'
`, mcuID).Scan(&total)
return total, err
}
// GetAbnormalCount returns distinct patients with at least one kelainan.
// If group is non-empty, counts only patients with kelainan in that group.
func GetAbnormalCount(mcuID int, group string) (int, error) {
var count int
var err error
if group == "" {
err = db.DB.QueryRow(`
SELECT COUNT(DISTINCT M_PatientID)
FROM kelainan_details
WHERE Mgm_McuID = ?
`, mcuID).Scan(&count)
} else {
err = db.DB.QueryRow(`
SELECT COUNT(DISTINCT M_PatientID)
FROM kelainan_details
WHERE Mgm_McuID = ?
AND Mcu_KelainanGroupSummaryName = ?
`, mcuID, group).Scan(&count)
}
return count, err
}
// GetAbnormalGroups returns distinct kelainan group names for a project.
func GetAbnormalGroups(mcuID int) ([]string, error) {
rows, err := db.DB.Query(`
SELECT DISTINCT Mcu_KelainanGroupSummaryName
FROM kelainan_details
WHERE Mgm_McuID = ?
AND Mcu_KelainanGroupSummaryName IS NOT NULL
AND Mcu_KelainanGroupSummaryName != ''
ORDER BY Mcu_KelainanGroupSummaryName
`, mcuID)
if err != nil {
return nil, err
}
defer rows.Close()
var groups []string
for rows.Next() {
var g string
if err := rows.Scan(&g); err != nil {
continue
}
groups = append(groups, g)
}
return groups, rows.Err()
}
// GetAgeChartData returns abnormal patient counts by age bucket.
func GetAgeChartData(mcuID int, group string) (AgeChartData, error) {
buckets := []string{"<30", "30-39", "40-49", "50+"}
counts := make(map[string]int)
var query string
var args []any
if group == "" {
query = `
SELECT
CASE
WHEN AgePatient < 30 THEN '<30'
WHEN AgePatient < 40 THEN '30-39'
WHEN AgePatient < 50 THEN '40-49'
ELSE '50+'
END AS age_group,
COUNT(DISTINCT M_PatientID) AS cnt
FROM kelainan_details
WHERE Mgm_McuID = ?
AND AgePatient IS NOT NULL
GROUP BY age_group`
args = []any{mcuID}
} else {
query = `
SELECT
CASE
WHEN AgePatient < 30 THEN '<30'
WHEN AgePatient < 40 THEN '30-39'
WHEN AgePatient < 50 THEN '40-49'
ELSE '50+'
END AS age_group,
COUNT(DISTINCT M_PatientID) AS cnt
FROM kelainan_details
WHERE Mgm_McuID = ?
AND Mcu_KelainanGroupSummaryName = ?
AND AgePatient IS NOT NULL
GROUP BY age_group`
args = []any{mcuID, group}
}
rows, err := db.DB.Query(query, args...)
if err != nil {
return AgeChartData{}, err
}
defer rows.Close()
for rows.Next() {
var label string
var cnt int
if err := rows.Scan(&label, &cnt); err != nil {
continue
}
counts[label] = cnt
}
data := AgeChartData{Labels: buckets, Abnormal: make([]int, len(buckets))}
for i, b := range buckets {
data.Abnormal[i] = counts[b]
}
return data, rows.Err()
}
// GetGenderChartData returns abnormal patient counts by gender.
func GetGenderChartData(mcuID int, group string) (GenderChartData, error) {
labels := []string{"Male", "Female"}
counts := make(map[string]int)
var query string
var args []any
if group == "" {
query = `
SELECT
CASE WHEN LOWER(M_PatientGender) = 'male' THEN 'Male' ELSE 'Female' END AS gender,
COUNT(DISTINCT M_PatientID) AS cnt
FROM kelainan_details
WHERE Mgm_McuID = ?
AND M_PatientGender IS NOT NULL
GROUP BY gender`
args = []any{mcuID}
} else {
query = `
SELECT
CASE WHEN LOWER(M_PatientGender) = 'male' THEN 'Male' ELSE 'Female' END AS gender,
COUNT(DISTINCT M_PatientID) AS cnt
FROM kelainan_details
WHERE Mgm_McuID = ?
AND Mcu_KelainanGroupSummaryName = ?
AND M_PatientGender IS NOT NULL
GROUP BY gender`
args = []any{mcuID, group}
}
rows, err := db.DB.Query(query, args...)
if err != nil {
return GenderChartData{}, err
}
defer rows.Close()
for rows.Next() {
var label string
var cnt int
if err := rows.Scan(&label, &cnt); err != nil {
continue
}
counts[label] = cnt
}
data := GenderChartData{Labels: labels, Abnormal: make([]int, len(labels))}
for i, l := range labels {
data.Abnormal[i] = counts[l]
}
return data, rows.Err()
}
// GetDeptChartData returns top-10 departments by abnormal patient count.
func GetDeptChartData(mcuID int, group string) (DeptChartData, error) {
var query string
var args []any
if group == "" {
query = `
SELECT
COALESCE(
NULLIF(TRIM(M_PatientDepartement), ''),
NULLIF(TRIM(M_PatientDivisi), ''),
NULLIF(TRIM(M_PatientPosisi), ''),
'-'
) AS dept,
COUNT(DISTINCT M_PatientID) AS cnt
FROM kelainan_details
WHERE Mgm_McuID = ?
GROUP BY dept
ORDER BY cnt DESC
LIMIT 10`
args = []any{mcuID}
} else {
query = `
SELECT
COALESCE(
NULLIF(TRIM(M_PatientDepartement), ''),
NULLIF(TRIM(M_PatientDivisi), ''),
NULLIF(TRIM(M_PatientPosisi), ''),
'-'
) AS dept,
COUNT(DISTINCT M_PatientID) AS cnt
FROM kelainan_details
WHERE Mgm_McuID = ?
AND Mcu_KelainanGroupSummaryName = ?
GROUP BY dept
ORDER BY cnt DESC
LIMIT 10`
args = []any{mcuID, group}
}
rows, err := db.DB.Query(query, args...)
if err != nil {
return DeptChartData{}, err
}
defer rows.Close()
var data DeptChartData
for rows.Next() {
var label string
var cnt int
if err := rows.Scan(&label, &cnt); err != nil {
continue
}
data.Labels = append(data.Labels, label)
data.Abnormal = append(data.Abnormal, cnt)
}
return data, rows.Err()
}

View File

@@ -0,0 +1,7 @@
package abnormal
import "github.com/go-chi/chi/v5"
func Routes(r chi.Router) {
r.Get("/", Index)
}

View File

@@ -0,0 +1,128 @@
package arrival
import (
"cpone-dashboard/menu/auth"
"cpone-dashboard/menu/projects"
"encoding/json"
"html/template"
"net/http"
)
var tmpl *template.Template
var basePath string
func SetTemplates(t *template.Template) { tmpl = t }
func SetBasePath(p string) { basePath = p }
type pageData struct {
Username string
CurrentProject projects.ProjectItem
Date string
AvailableDates []string
Search string
Department string
Rows []ArrivalRow
FilteredRows []ArrivalRow
Summary ArrivalSummary
Departments []DepartmentStat
DepartmentOptions []string
OverviewJSON template.JS
DepartmentJSON template.JS
}
func toJS(v interface{}) template.JS {
b, _ := json.Marshal(v)
return template.JS(b)
}
func Index(w http.ResponseWriter, r *http.Request) {
username := auth.Username(r)
mcuID := auth.SelectedProjectID(r)
if mcuID == 0 {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
project, ok, err := projects.GetUserProject(username, mcuID)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
if !ok {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
dates, err := GetArrivalDates(mcuID)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
selectedDate := activeDateOrLatest(dates, r.URL.Query().Get("date"), project.StartDate)
rows, err := GetArrivalRows(mcuID, selectedDate)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
summary, deptStats := BuildArrivalStats(rows)
filteredRows := FilterArrivalRows(rows, r.URL.Query().Get("search"), r.URL.Query().Get("dept"))
deptOptions := UniqueDepartments(rows)
stationMap, err := GetStationProgress(mcuID, selectedDate)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
for i := range filteredRows {
filteredRows[i].Stations = stationMap[filteredRows[i].PreregisterID]
}
// Chart 1: double donut — inner: checked-in vs pending, outer: total per posisi/dept
outerDepts := []map[string]any{}
for _, d := range deptStats {
if d.Total > 0 {
outerDepts = append(outerDepts, map[string]any{"name": d.Name, "value": d.Total})
}
}
overview := map[string]any{
"checkedIn": summary.CheckedIn,
"pending": summary.Pending,
"depts": outerDepts,
}
// Chart 2: per-station patient count bar chart
stationStats := BuildStationChart(stationMap)
stationLabels := make([]string, len(stationStats))
stationCounts := make([]int, len(stationStats))
for i, s := range stationStats {
stationLabels[i] = s.Name
stationCounts[i] = s.Count
}
stationChart := map[string]any{
"labels": stationLabels,
"counts": stationCounts,
}
t := tmpl
if t == nil {
http.Error(w, "template not ready", http.StatusInternalServerError)
return
}
data := pageData{
Username: username,
CurrentProject: project,
Date: selectedDate,
AvailableDates: dates,
Search: r.URL.Query().Get("search"),
Department: r.URL.Query().Get("dept"),
Rows: rows,
FilteredRows: filteredRows,
Summary: summary,
Departments: deptStats,
DepartmentOptions: deptOptions,
OverviewJSON: toJS(overview),
DepartmentJSON: toJS(stationChart),
}
if err := t.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,307 @@
package arrival
import (
"cpone-dashboard/db"
"fmt"
"sort"
"strings"
)
type StationBadge struct {
Name string
Tone string // "success" | "warning" | "danger" | "neutral"
}
type StationStat struct {
Name string
Count int
}
type ArrivalRow struct {
PreregisterID int
NIP string
Name string
Department string
InTime string
Status string
StatusTone string
Stations []StationBadge
}
type DepartmentStat struct {
Name string
CheckedIn int
Pending int
Total int
}
type ArrivalSummary struct {
CheckedIn int
Pending int
Total int
}
func GetArrivalDates(mcuID int) ([]string, error) {
rows, err := db.DB.Query(`
SELECT DATE_FORMAT(Mcu_PatientScheduleDate, '%Y-%m-%d') AS schedule_date
FROM mcu_patient_schedule
WHERE Mcu_PatientSchedulePreregisterID IN (
SELECT Mcu_PatientPreregisterID
FROM mcu_patient
WHERE Mcu_PatientMcuID = ?
AND Mcu_PatientIsActive = 'Y'
)
AND Mcu_PatientScheduleIsActive = 'Y'
GROUP BY Mcu_PatientScheduleDate
ORDER BY Mcu_PatientScheduleDate DESC
`, mcuID)
if err != nil {
return nil, err
}
defer rows.Close()
var dates []string
for rows.Next() {
var d string
if rows.Scan(&d) == nil && d != "" {
dates = append(dates, d)
}
}
return dates, nil
}
func GetArrivalRows(mcuID int, date string) ([]ArrivalRow, error) {
rows, err := db.DB.Query(`
SELECT
mp.Mcu_PatientPreregisterID,
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '') AS nip,
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '') AS patient_name,
COALESCE(
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
'-'
) AS department_name,
COALESCE(DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i'), '') AS in_time,
CASE
WHEN mc.Mcu_CheckinoutOutTime IS NOT NULL THEN 'Performed'
WHEN mc.Mcu_CheckinoutInTime IS NOT NULL THEN 'In Progress'
ELSE 'Not Check-in Yet'
END AS status_text,
CASE
WHEN mc.Mcu_CheckinoutOutTime IS NOT NULL THEN 'success'
WHEN mc.Mcu_CheckinoutInTime IS NOT NULL THEN 'warning'
ELSE 'neutral'
END AS status_tone
FROM mcu_patient_schedule s
JOIN mcu_patient mp
ON mp.Mcu_PatientPreregisterID = s.Mcu_PatientSchedulePreregisterID
AND mp.Mcu_PatientMcuID = ?
AND mp.Mcu_PatientIsActive = 'Y'
LEFT JOIN mcu_checkinout mc
ON mc.Mcu_CheckinoutPreregisterID = mp.Mcu_PatientPreregisterID
AND mc.Mcu_CheckinoutMcuID = ?
AND mc.Mcu_CheckinoutDate = s.Mcu_PatientScheduleDate
AND mc.Mcu_CheckinoutIsActive = 'Y'
WHERE s.Mcu_PatientScheduleIsActive = 'Y'
AND s.Mcu_PatientScheduleDate = ?
ORDER BY
CASE WHEN mc.Mcu_CheckinoutInTime IS NULL THEN 1 ELSE 0 END,
mc.Mcu_CheckinoutInTime DESC,
mp.Mcu_PatientName ASC
`, mcuID, mcuID, date)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ArrivalRow
for rows.Next() {
var r ArrivalRow
if err := rows.Scan(&r.PreregisterID, &r.NIP, &r.Name, &r.Department, &r.InTime, &r.Status, &r.StatusTone); err != nil {
continue
}
if strings.TrimSpace(r.NIP) == "" {
r.NIP = "-"
}
if strings.TrimSpace(r.Name) == "" {
r.Name = "-"
}
if strings.TrimSpace(r.Department) == "" {
r.Department = "-"
}
result = append(result, r)
}
return result, nil
}
func GetStationProgress(mcuID int, date string) (map[int][]StationBadge, error) {
rows, err := db.DB.Query(`
SELECT
sp.Mcu_StationProgressPreregisterID,
sp.Mcu_StationProgressStationName,
CASE
WHEN sp.Mcu_StationProgressDoneAt IS NOT NULL THEN 'success'
WHEN sp.Mcu_StationProgressProcessAt IS NOT NULL
OR sp.Mcu_StationProgressReceiveAt IS NOT NULL
OR sp.Mcu_StationProgressSamplingAt IS NOT NULL THEN 'warning'
ELSE 'neutral'
END AS tone
FROM mcu_station_progress sp
WHERE sp.Mcu_StationProgressMcuID = ?
AND sp.Mcu_StationProgressPreregisterID IN (
SELECT mp.Mcu_PatientPreregisterID
FROM mcu_patient_schedule s
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = s.Mcu_PatientSchedulePreregisterID
WHERE mp.Mcu_PatientMcuID = ?
AND mp.Mcu_PatientIsActive = 'Y'
AND s.Mcu_PatientScheduleDate = ?
AND s.Mcu_PatientScheduleIsActive = 'Y'
)
ORDER BY sp.Mcu_StationProgressPreregisterID, sp.Mcu_StationProgressStationName
`, mcuID, mcuID, date)
if err != nil {
return nil, err
}
defer rows.Close()
result := map[int][]StationBadge{}
for rows.Next() {
var preregID int
var name, tone string
if err := rows.Scan(&preregID, &name, &tone); err != nil {
continue
}
result[preregID] = append(result[preregID], StationBadge{Name: name, Tone: tone})
}
return result, nil
}
func BuildArrivalStats(rows []ArrivalRow) (ArrivalSummary, []DepartmentStat) {
summary := ArrivalSummary{Total: len(rows)}
deptMap := map[string]*DepartmentStat{}
for _, row := range rows {
if row.InTime != "" {
summary.CheckedIn++
}
dept := row.Department
if dept == "" {
dept = "-"
}
stat, ok := deptMap[dept]
if !ok {
stat = &DepartmentStat{Name: dept}
deptMap[dept] = stat
}
stat.Total++
if row.InTime != "" {
stat.CheckedIn++
}
}
summary.Pending = summary.Total - summary.CheckedIn
if summary.Pending < 0 {
summary.Pending = 0
}
stats := make([]DepartmentStat, 0, len(deptMap))
for _, stat := range deptMap {
stat.Pending = stat.Total - stat.CheckedIn
if stat.Pending < 0 {
stat.Pending = 0
}
stats = append(stats, *stat)
}
sort.Slice(stats, func(i, j int) bool {
if stats[i].CheckedIn != stats[j].CheckedIn {
return stats[i].CheckedIn > stats[j].CheckedIn
}
if stats[i].Total != stats[j].Total {
return stats[i].Total > stats[j].Total
}
return stats[i].Name < stats[j].Name
})
return summary, stats
}
func BuildStationChart(stationMap map[int][]StationBadge) []StationStat {
countMap := map[string]int{}
for _, badges := range stationMap {
for _, b := range badges {
countMap[b.Name]++
}
}
stats := make([]StationStat, 0, len(countMap))
for name, count := range countMap {
stats = append(stats, StationStat{Name: name, Count: count})
}
sort.Slice(stats, func(i, j int) bool {
return stats[i].Count > stats[j].Count
})
return stats
}
func FilterArrivalRows(rows []ArrivalRow, search, dept string) []ArrivalRow {
search = strings.ToLower(strings.TrimSpace(search))
dept = strings.TrimSpace(dept)
if search == "" && dept == "" {
return rows
}
out := make([]ArrivalRow, 0, len(rows))
for _, row := range rows {
if dept != "" && dept != "All Departments" && row.Department != dept {
continue
}
if search != "" {
hay := strings.ToLower(row.Name + " " + row.NIP + " " + row.Department)
if !strings.Contains(hay, search) {
continue
}
}
out = append(out, row)
}
return out
}
func UniqueDepartments(rows []ArrivalRow) []string {
seen := map[string]struct{}{}
var out []string
for _, row := range rows {
name := strings.TrimSpace(row.Department)
if name == "" {
name = "-"
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
sort.Strings(out)
return out
}
func activeDateOrLatest(dates []string, selected string, fallback string) string {
selected = strings.TrimSpace(selected)
if selected != "" {
for _, d := range dates {
if d == selected {
return selected
}
}
}
if len(dates) > 0 {
return dates[0]
}
return fallback
}
func mustDayLabel(date string) string {
if len(date) >= 10 {
return fmt.Sprintf("%s/%s/%s", date[8:10], date[5:7], date[0:4])
}
return date
}

View File

@@ -0,0 +1,7 @@
package arrival
import "github.com/go-chi/chi/v5"
func Routes(r chi.Router) {
r.Get("/", Index)
}

View File

@@ -0,0 +1,69 @@
package auth
import (
"cpone-dashboard/db"
"embed"
"html/template"
"net/http"
)
var (
tmplFS *embed.FS
authSecret string
basePath string
)
func Init(fs *embed.FS, secret string) {
tmplFS = fs
authSecret = secret
}
func SetBasePath(p string) { basePath = p }
type loginData struct {
Error string
}
func ShowLogin(w http.ResponseWriter, r *http.Request) {
if getSession(r, authSecret) != "" {
http.Redirect(w, r, basePath+"/dashboard", http.StatusSeeOther)
return
}
render(w, loginData{})
}
func DoLogin(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
var hash, salt string
err := db.DB.QueryRow(
`SELECT User_Password, COALESCE(User_Salt, '')
FROM dashboard_user
WHERE User_Username = ? AND User_IsActive = 'Y'`,
username,
).Scan(&hash, &salt)
if err != nil || !checkPassword(password, salt, hash) {
w.WriteHeader(http.StatusUnauthorized)
render(w, loginData{Error: "Username atau password salah."})
return
}
SetSession(w, username, authSecret)
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
}
func DoLogout(w http.ResponseWriter, r *http.Request) {
ClearSession(w)
http.Redirect(w, r, basePath+"/mcu-login", http.StatusSeeOther)
}
func checkPassword(password, salt, storedHash string) bool {
return hashPassword(password, salt) == storedHash
}
func render(w http.ResponseWriter, data loginData) {
b := func(path string) string { return basePath + path }
t := template.Must(template.New("").Funcs(template.FuncMap{"b": b}).ParseFS(tmplFS, "templates/login/index.html"))
t.ExecuteTemplate(w, "login", data)
}

View File

@@ -0,0 +1,16 @@
package auth
import "net/http"
func Require(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username := getSession(r, secret)
if username == "" {
http.Redirect(w, r, basePath+"/mcu-login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, setContext(r, username))
})
}
}

View File

@@ -0,0 +1,79 @@
package auth
import (
"cpone-dashboard/db"
"crypto/sha256"
"encoding/hex"
"html/template"
"net/http"
)
type passwordData struct {
Username string
Error string
Success string
}
func ShowPassword(w http.ResponseWriter, r *http.Request) {
renderPassword(w, passwordData{Username: Username(r)})
}
func DoChangePassword(w http.ResponseWriter, r *http.Request) {
username := Username(r)
current := r.FormValue("current_password")
newPass := r.FormValue("new_password")
confirm := r.FormValue("confirm_password")
fail := func(msg string) {
w.WriteHeader(http.StatusUnprocessableEntity)
renderPassword(w, passwordData{Username: username, Error: msg})
}
if newPass != confirm {
fail("Password baru dan konfirmasi tidak cocok.")
return
}
if len(newPass) < 6 {
fail("Password baru minimal 6 karakter.")
return
}
// Verifikasi password lama
var storedHash, salt string
err := db.DB.QueryRow(
`SELECT User_Password, COALESCE(User_Salt, '')
FROM dashboard_user WHERE User_Username = ? AND User_IsActive = 'Y'`,
username,
).Scan(&storedHash, &salt)
if err != nil || !checkPassword(current, salt, storedHash) {
fail("Password saat ini tidak sesuai.")
return
}
// Generate salt + hash baru
newSalt := newUUID()
newHash := hashPassword(newPass, newSalt)
_, err = db.DB.Exec(
`UPDATE dashboard_user
SET User_Password = ?, User_Salt = ?, User_UpdatedAt = NOW()
WHERE User_Username = ?`,
newHash, newSalt, username,
)
if err != nil {
fail("Gagal menyimpan password baru, coba lagi.")
return
}
renderPassword(w, passwordData{Username: username, Success: "Password berhasil diubah."})
}
func renderPassword(w http.ResponseWriter, data passwordData) {
t := template.Must(template.ParseFS(tmplFS, "templates/auth/password.html"))
t.ExecuteTemplate(w, "password", data)
}
func hashPassword(password, salt string) string {
h := sha256.Sum256([]byte(salt + ":" + password))
return hex.EncodeToString(h[:])
}

View File

@@ -0,0 +1,14 @@
package auth
import "github.com/go-chi/chi/v5"
func Routes(r chi.Router) {
r.Get("/mcu-login", ShowLogin)
r.Post("/mcu-login", DoLogin)
r.Get("/logout", DoLogout)
}
func ProtectedRoutes(r chi.Router) {
r.Get("/password", ShowPassword)
r.Post("/password", DoChangePassword)
}

View File

@@ -0,0 +1,124 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
)
const cookieName = "cpone_session"
const projectCookieName = "cpone_project_mcu_id"
type ctxKey string
const userCtxKey ctxKey = "username"
// Username returns the logged-in username from the request context.
func Username(r *http.Request) string {
v, _ := r.Context().Value(userCtxKey).(string)
return v
}
func setContext(r *http.Request, username string) *http.Request {
return r.WithContext(context.WithValue(r.Context(), userCtxKey, username))
}
func SetSession(w http.ResponseWriter, username, secret string) {
val := encode(username) + "." + sign(username, secret)
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: val,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(24 * time.Hour),
})
}
func ClearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
MaxAge: -1,
})
}
func SetSelectedProject(w http.ResponseWriter, mcuID int) {
http.SetCookie(w, &http.Cookie{
Name: projectCookieName,
Value: strconv.Itoa(mcuID),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(7 * 24 * time.Hour),
})
}
func ClearSelectedProject(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: projectCookieName,
Value: "",
Path: "/",
MaxAge: -1,
})
}
func SelectedProjectID(r *http.Request) int {
c, err := r.Cookie(projectCookieName)
if err != nil {
return 0
}
id, err := strconv.Atoi(strings.TrimSpace(c.Value))
if err != nil {
return 0
}
return id
}
func getSession(r *http.Request, secret string) string {
c, err := r.Cookie(cookieName)
if err != nil {
return ""
}
parts := strings.SplitN(c.Value, ".", 2)
if len(parts) != 2 {
return ""
}
username, err := decode(parts[0])
if err != nil || username == "" {
return ""
}
if !hmac.Equal([]byte(sign(username, secret)), []byte(parts[1])) {
return ""
}
return username
}
func newUUID() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
func sign(username, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(username))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func encode(s string) string {
return base64.RawURLEncoding.EncodeToString([]byte(s))
}
func decode(s string) (string, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
return string(b), err
}

View File

@@ -0,0 +1,287 @@
package dashboard
import (
"cpone-dashboard/menu/auth"
"cpone-dashboard/menu/projects"
"embed"
"encoding/json"
"html/template"
"log"
"net/http"
"strconv"
"time"
)
var templateFS *embed.FS
var funcMap template.FuncMap
type StationsPartial struct {
Rows []StationRow
IsLive bool
}
type ArrivalsPartial struct {
Rows []ArrivalRow
IsLive bool
}
type PatientsPartial struct {
Patients []PatientDetail
IsRange bool
}
type PageData struct {
Username string
McuID int
Project ProjectInfo
CurrentProject projects.ProjectItem
AvailableDates []string
Mode string
DateFrom string
DateTo string
IsRange bool
IsLive bool
KPI KPIData
TAT TATData
Stations []StationRow
Arrivals []ArrivalRow
TATChart template.JS
TrendChart template.JS
}
var basePath string
func SetTemplateFS(fs *embed.FS) { templateFS = fs }
func SetTemplateFuncs(fm template.FuncMap) { funcMap = fm }
func SetBasePath(p string) { basePath = p }
func parse(files ...string) *template.Template {
return template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, files...))
}
func defaultDailyDate(project ProjectInfo) string {
today := time.Now().Format("2006-01-02")
if project.StartDate == "" {
return today
}
if project.EndDate != "" && today > project.EndDate {
return project.StartDate
}
if today < project.StartDate {
return project.StartDate
}
return today
}
func containsDate(dates []string, target string) bool {
for _, d := range dates {
if d == target {
return true
}
}
return false
}
func activeDateRange(r *http.Request, project ProjectInfo, availableDates []string) (mode, from, to string) {
mode = "daily"
reqDate := r.URL.Query().Get("date")
switch {
case reqDate != "" && containsDate(availableDates, reqDate):
from = reqDate
case containsDate(availableDates, time.Now().Format("2006-01-02")):
from = time.Now().Format("2006-01-02")
case len(availableDates) > 0:
from = availableDates[0]
default:
from = defaultDailyDate(project)
}
to = from
return
}
func activeMcuID(r *http.Request) int {
if id, _ := strconv.Atoi(r.URL.Query().Get("mcu_id")); id > 0 {
return id
}
return auth.SelectedProjectID(r)
}
func toJS(v interface{}) template.JS {
b, _ := json.Marshal(v)
return template.JS(b)
}
func Index(w http.ResponseWriter, r *http.Request) {
mcuID := activeMcuID(r)
username := auth.Username(r)
if mcuID == 0 {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
ok, err := projects.HasAccess(username, mcuID)
if err != nil {
log.Printf("[dashboard] HasAccess error: %v", err)
http.Error(w, "query error", http.StatusInternalServerError)
return
}
if !ok {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
project, err := GetProject(mcuID)
if err != nil {
log.Printf("[dashboard] GetActiveProject error: %v", err)
http.Error(w, "query error", http.StatusInternalServerError)
return
}
currentProject := projects.ProjectItem{}
if item, ok, err := projects.GetUserProject(username, mcuID); err != nil {
log.Printf("[dashboard] GetUserProject error: %v", err)
} else if ok {
currentProject = item
auth.SetSelectedProject(w, mcuID)
}
availableDates, err := GetCheckinDates(project.McuID)
if err != nil {
log.Printf("[dashboard] GetCheckinDates error: %v", err)
}
mode, dateFrom, dateTo := activeDateRange(r, project, availableDates)
isRange := dateFrom != dateTo
kpi, err := GetKPI(project.McuID, dateFrom, dateTo)
if err != nil {
log.Printf("[dashboard] GetKPI error: %v", err)
}
tat, err := GetTAT(project.McuID, dateFrom, dateTo)
if err != nil {
log.Printf("[dashboard] GetTAT error: %v", err)
}
stations, err := GetStations(project.McuID, dateFrom, dateTo)
if err != nil {
log.Printf("[dashboard] GetStations error: %v", err)
}
arrivals, err := GetArrivals(project.McuID, dateFrom, dateTo, 8)
if err != nil {
log.Printf("[dashboard] GetArrivals error: %v", err)
}
hourlyTAT, err := GetPeriodTAT(project.McuID, dateFrom, dateTo, isRange)
if err != nil {
log.Printf("[dashboard] GetPeriodTAT error: %v", err)
}
trend, err := GetPeriodTrend(project.McuID, dateFrom, dateTo, isRange)
if err != nil {
log.Printf("[dashboard] GetPeriodTrend error: %v", err)
}
// Build chart JSON payloads
tatLabels, tatValues := []string{}, []float64{}
for _, p := range hourlyTAT {
tatLabels = append(tatLabels, p.Label)
tatValues = append(tatValues, p.Value)
}
trendLabels, trendCI, trendCO := []string{}, []int{}, []int{}
for _, p := range trend {
trendLabels = append(trendLabels, p.Label)
trendCI = append(trendCI, p.CheckedIn)
trendCO = append(trendCO, p.CheckedOut)
}
today := time.Now().Format("2006-01-02")
data := PageData{
Username: username,
McuID: project.McuID,
Project: project,
CurrentProject: currentProject,
AvailableDates: availableDates,
Mode: mode,
DateFrom: dateFrom,
DateTo: dateTo,
IsRange: isRange,
IsLive: mode == "daily" && dateFrom == today,
KPI: kpi,
TAT: tat,
Stations: stations,
Arrivals: arrivals,
TATChart: toJS(map[string]interface{}{
"labels": tatLabels,
"values": tatValues,
}),
TrendChart: toJS(map[string]interface{}{
"labels": trendLabels,
"checkedIn": trendCI,
"checkedOut": trendCO,
}),
}
t := parse("templates/layout/base.html", "templates/dashboard/index.html")
if err := t.ExecuteTemplate(w, "base", data); err != nil {
log.Printf("[dashboard] template error: %v", err)
}
}
func Patients(w http.ResponseWriter, r *http.Request) {
project, err := GetProject(activeMcuID(r))
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
availableDates, _ := GetCheckinDates(project.McuID)
mode, dateFrom, dateTo := activeDateRange(r, project, availableDates)
patients, err := GetAllPatients(project.McuID, dateFrom, dateTo)
if err != nil {
log.Printf("[dashboard] GetAllPatients error: %v", err)
http.Error(w, "query error", http.StatusInternalServerError)
return
}
data := PatientsPartial{
Patients: patients,
IsRange: mode != "daily" || dateFrom != dateTo,
}
t := parse("templates/dashboard/partials/patients.html")
if err := t.ExecuteTemplate(w, "patients", data); err != nil {
log.Printf("[dashboard] patients template error: %v", err)
}
}
func KPI(w http.ResponseWriter, r *http.Request) {
project, _ := GetProject(activeMcuID(r))
availableDates, _ := GetCheckinDates(project.McuID)
_, from, to := activeDateRange(r, project, availableDates)
data, err := GetKPI(project.McuID, from, to)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
t := parse("templates/dashboard/partials/kpi.html")
t.ExecuteTemplate(w, "kpi", data)
}
func Stations(w http.ResponseWriter, r *http.Request) {
project, _ := GetProject(activeMcuID(r))
availableDates, _ := GetCheckinDates(project.McuID)
_, from, to := activeDateRange(r, project, availableDates)
rows, err := GetStations(project.McuID, from, to)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
t := parse("templates/dashboard/partials/stations.html")
t.ExecuteTemplate(w, "stations", StationsPartial{Rows: rows, IsLive: from == time.Now().Format("2006-01-02")})
}
func Arrivals(w http.ResponseWriter, r *http.Request) {
project, _ := GetProject(activeMcuID(r))
availableDates, _ := GetCheckinDates(project.McuID)
_, from, to := activeDateRange(r, project, availableDates)
rows, err := GetArrivals(project.McuID, from, to, 8)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
t := parse("templates/dashboard/partials/arrivals.html")
t.ExecuteTemplate(w, "arrivals", rows)
}

View File

@@ -0,0 +1,505 @@
package dashboard
import (
"cpone-dashboard/db"
"database/sql"
"fmt"
)
const checkinOutTimestampExpr = "TIMESTAMP(Mcu_CheckinoutDate, Mcu_CheckinoutOutTime)"
type ProjectInfo struct {
McuID int
CorporateName string
Label string
Number string
StartDate string
EndDate string
TotalStaff int
}
type KPIData struct {
InvitedStaff int // dari mcu_participant_daily, date-filtered — untuk widget kanan atas
TotalStaff int // dari mcu_checkinout, seluruh project — untuk KPI card
CheckedIn int // dari mcu_checkinout, date-filtered
CheckedOut int // dari mcu_checkinout, date-filtered
}
type StationRow struct {
Station string
Processed int
Pending int
Total int
Pct float64
}
type ArrivalRow struct {
Name string
InTime string
Date string
Station string
}
type TATData struct {
AvgMinutes int
Fastest int
Median int
CheckedOut int
}
type ChartPoint struct {
Label string
Value float64
}
type TrendPoint struct {
Label string
CheckedIn int
CheckedOut int
}
// GetProject returns project by mcuID. If mcuID == 0, returns the active project.
func GetProject(mcuID int) (ProjectInfo, error) {
var p ProjectInfo
var err error
const cols = `SELECT Mcu_ProjectMcuID, Mcu_ProjectCorporateName, Mcu_ProjectLabel,
Mcu_ProjectNumber,
DATE_FORMAT(Mcu_ProjectStartDate, '%Y-%m-%d'),
DATE_FORMAT(Mcu_ProjectEndDate, '%Y-%m-%d'),
Mcu_ProjectTotalParticipant
FROM mcu_project`
if mcuID > 0 {
err = db.DB.QueryRow(cols+` WHERE Mcu_ProjectMcuID = ?`, mcuID).
Scan(&p.McuID, &p.CorporateName, &p.Label, &p.Number, &p.StartDate, &p.EndDate, &p.TotalStaff)
} else {
err = db.DB.QueryRow(cols+` WHERE Mcu_ProjectIsActive = 'Y' ORDER BY Mcu_ProjectStartDate DESC LIMIT 1`).
Scan(&p.McuID, &p.CorporateName, &p.Label, &p.Number, &p.StartDate, &p.EndDate, &p.TotalStaff)
}
if err == sql.ErrNoRows {
return p, nil
}
return p, err
}
func GetKPI(mcuID int, dateFrom, dateTo string) (KPIData, error) {
var d KPIData
// Invited staff: dari mcu_participant_daily, date-filtered — widget kanan atas
db.DB.QueryRow(`
SELECT COALESCE(SUM(Mcu_ParticipantDailyTotal), 0)
FROM mcu_participant_daily
WHERE Mcu_ParticipantDailyMcuID = ?
AND Mcu_ParticipantDailyDate BETWEEN ? AND ?
AND Mcu_ParticipantDailyIsActive = 'Y'
`, mcuID, dateFrom, dateTo).Scan(&d.InvitedStaff)
// Total staff: semua yang datang (checkin) pada tanggal filter
db.DB.QueryRow(`
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutIsActive = 'Y'
`, mcuID, dateFrom, dateTo).Scan(&d.TotalStaff)
// Checked-in: masih di dalam (belum checkout)
db.DB.QueryRow(`
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutOutTime IS NULL
AND Mcu_CheckinoutIsActive = 'Y'
`, mcuID, dateFrom, dateTo).Scan(&d.CheckedIn)
// Checked-out: sudah selesai
db.DB.QueryRow(`
SELECT COUNT(DISTINCT Mcu_CheckinoutPreregisterID) FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutOutTime IS NOT NULL
AND Mcu_CheckinoutIsActive = 'Y'
`, mcuID, dateFrom, dateTo).Scan(&d.CheckedOut)
return d, nil
}
func GetCheckinDates(mcuID int) ([]string, error) {
rows, err := db.DB.Query(`
SELECT DATE_FORMAT(Mcu_CheckinoutDate, '%Y-%m-%d') AS checkin_date
FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutIsActive = 'Y'
GROUP BY Mcu_CheckinoutDate
ORDER BY Mcu_CheckinoutDate DESC
`, mcuID)
if err != nil {
return nil, err
}
defer rows.Close()
var dates []string
for rows.Next() {
var d string
if rows.Scan(&d) == nil && d != "" {
dates = append(dates, d)
}
}
return dates, nil
}
func GetTAT(mcuID int, dateFrom, dateTo string) (TATData, error) {
var d TATData
rows, err := db.DB.Query(`
SELECT TIMESTAMPDIFF(MINUTE,
TIMESTAMP(Mcu_CheckinoutDate, Mcu_CheckinoutInTime),
`+checkinOutTimestampExpr+`
) AS tat
FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutOutTime IS NOT NULL
AND Mcu_CheckinoutInTime IS NOT NULL
AND Mcu_CheckinoutIsActive = 'Y'
ORDER BY tat
`, mcuID, dateFrom, dateTo)
if err != nil {
return d, err
}
defer rows.Close()
var vals []int
for rows.Next() {
var v int
if rows.Scan(&v) == nil && v > 0 {
vals = append(vals, v)
}
}
if len(vals) == 0 {
return d, nil
}
sum := 0
for _, v := range vals {
sum += v
}
d.CheckedOut = len(vals)
d.AvgMinutes = sum / len(vals)
d.Fastest = vals[0]
d.Median = vals[len(vals)/2]
return d, nil
}
func GetStations(mcuID int, dateFrom, dateTo string) ([]StationRow, error) {
rows, err := db.DB.Query(`
SELECT rs.station_name,
COUNT(DISTINCT CASE
WHEN spd.first_done_date IS NULL OR spd.first_done_date >= mc.Mcu_CheckinoutDate
THEN mc.Mcu_CheckinoutPreregisterID
END) AS total_required,
COUNT(DISTINCT CASE
WHEN spd.first_done_date = mc.Mcu_CheckinoutDate
THEN mc.Mcu_CheckinoutPreregisterID
ELSE NULL
END) AS processed
FROM mcu_checkinout mc
JOIN mcu_patient_required_station rs
ON rs.preregister_id = mc.Mcu_CheckinoutPreregisterID
AND rs.mcu_id = mc.Mcu_CheckinoutMcuID
LEFT JOIN (
SELECT
sp.Mcu_StationProgressPreregisterID AS preregister_id,
sp.Mcu_StationProgressStationID AS station_id,
MIN(CASE
WHEN sp.Mcu_StationProgressSource = 'lab'
AND sp.Mcu_StationProgressReceiveAt IS NOT NULL
THEN DATE(sp.Mcu_StationProgressReceiveAt)
WHEN sp.Mcu_StationProgressSource = 'nonlab'
AND sp.Mcu_StationProgressDoneAt IS NOT NULL
THEN DATE(sp.Mcu_StationProgressDoneAt)
ELSE NULL
END) AS first_done_date
FROM mcu_station_progress sp
WHERE sp.Mcu_StationProgressMcuID = ?
GROUP BY sp.Mcu_StationProgressPreregisterID, sp.Mcu_StationProgressStationID
) spd
ON spd.preregister_id = mc.Mcu_CheckinoutPreregisterID
AND spd.station_id = rs.sample_station_id
WHERE mc.Mcu_CheckinoutMcuID = ?
AND mc.Mcu_CheckinoutDate BETWEEN ? AND ?
AND mc.Mcu_CheckinoutIsActive = 'Y'
GROUP BY rs.station_name
HAVING total_required > 0
ORDER BY processed DESC, rs.station_name ASC
`, mcuID, mcuID, dateFrom, dateTo)
if err != nil {
return nil, err
}
defer rows.Close()
var result []StationRow
for rows.Next() {
var r StationRow
if err := rows.Scan(&r.Station, &r.Total, &r.Processed); err != nil {
continue
}
r.Pending = r.Total - r.Processed
if r.Pending < 0 {
r.Pending = 0
}
if r.Total > 0 {
r.Pct = float64(r.Processed) / float64(r.Total) * 100
}
result = append(result, r)
}
return result, nil
}
func GetArrivals(mcuID int, dateFrom, dateTo string, limit int) ([]ArrivalRow, error) {
rows, err := db.DB.Query(`
SELECT mp.Mcu_PatientName,
DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i:%s') AS in_time,
DATE_FORMAT(mc.Mcu_CheckinoutDate, '%Y-%m-%d') AS checkin_date,
COALESCE(
(SELECT Mcu_StationProgressStationName
FROM mcu_station_progress
WHERE Mcu_StationProgressPreregisterID = mc.Mcu_CheckinoutPreregisterID
AND Mcu_StationProgressCheckinDate = mc.Mcu_CheckinoutDate
AND Mcu_StationProgressDoneAt IS NOT NULL
ORDER BY Mcu_StationProgressDoneAt DESC LIMIT 1),
'Check-in'
) AS last_station
FROM mcu_checkinout mc
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = mc.Mcu_CheckinoutPreregisterID
WHERE mc.Mcu_CheckinoutMcuID = ?
AND mc.Mcu_CheckinoutDate BETWEEN ? AND ?
AND mc.Mcu_CheckinoutIsActive = 'Y'
ORDER BY mc.Mcu_CheckinoutDate DESC, mc.Mcu_CheckinoutInTime DESC
LIMIT ?
`, mcuID, dateFrom, dateTo, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ArrivalRow
for rows.Next() {
var r ArrivalRow
if rows.Scan(&r.Name, &r.InTime, &r.Date, &r.Station) == nil {
result = append(result, r)
}
}
return result, nil
}
// GetPeriodTAT — tampilkan agregasi per jam untuk daily maupun range
func GetPeriodTAT(mcuID int, dateFrom, dateTo string, isRange bool) ([]ChartPoint, error) {
_ = isRange
groupExpr := "HOUR(Mcu_CheckinoutInTime)"
labelExpr := "CONCAT(LPAD(HOUR(Mcu_CheckinoutInTime), 2, '0'), ':00')"
q := fmt.Sprintf(`
SELECT %s AS label,
AVG(TIMESTAMPDIFF(MINUTE,
TIMESTAMP(Mcu_CheckinoutDate, Mcu_CheckinoutInTime),
%s
)) AS avg_tat
FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutOutTime IS NOT NULL
AND Mcu_CheckinoutInTime IS NOT NULL
AND Mcu_CheckinoutIsActive = 'Y'
GROUP BY %s
ORDER BY %s
`, labelExpr, checkinOutTimestampExpr, groupExpr, groupExpr)
rows, err := db.DB.Query(q, mcuID, dateFrom, dateTo)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ChartPoint
for rows.Next() {
var p ChartPoint
if rows.Scan(&p.Label, &p.Value) == nil {
result = append(result, p)
}
}
return result, nil
}
// GetPeriodTrend — tampilkan hitungan per jam untuk daily maupun range
func GetPeriodTrend(mcuID int, dateFrom, dateTo string, isRange bool) ([]TrendPoint, error) {
_ = isRange
q := `
SELECT hour_no,
CONCAT(LPAD(hour_no, 2, '0'), ':00') AS label,
SUM(checked_in) AS checked_in,
SUM(checked_out) AS checked_out
FROM (
SELECT HOUR(Mcu_CheckinoutInTime) AS hour_no,
COUNT(*) AS checked_in,
0 AS checked_out
FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutInTime IS NOT NULL
AND Mcu_CheckinoutIsActive = 'Y'
GROUP BY HOUR(Mcu_CheckinoutInTime)
UNION ALL
SELECT HOUR(Mcu_CheckinoutOutTime) AS hour_no,
0 AS checked_in,
COUNT(*) AS checked_out
FROM mcu_checkinout
WHERE Mcu_CheckinoutMcuID = ?
AND Mcu_CheckinoutDate BETWEEN ? AND ?
AND Mcu_CheckinoutOutTime IS NOT NULL
AND Mcu_CheckinoutIsActive = 'Y'
GROUP BY HOUR(Mcu_CheckinoutOutTime)
) t
GROUP BY hour_no
ORDER BY hour_no
`
rows, err := db.DB.Query(q, mcuID, dateFrom, dateTo, mcuID, dateFrom, dateTo)
if err != nil {
return nil, err
}
defer rows.Close()
// Build cumulative
var points []TrendPoint
cumCI, cumCO := 0, 0
for rows.Next() {
var hourNo int
var label string
var ci, co int
if rows.Scan(&hourNo, &label, &ci, &co) == nil {
cumCI += ci
cumCO += co
points = append(points, TrendPoint{
Label: label,
CheckedIn: cumCI,
CheckedOut: cumCO,
})
}
}
return points, nil
}
type PatientStationStatus struct {
Station string
Done bool
ProcessAt string
DoneAt string
}
type PatientDetail struct {
ID int
Name string
Date string
InTime string
OutTime string
HasOut bool
Stations []PatientStationStatus
DoneCount int
}
type patientKey struct {
ID int
Date string
}
func GetAllPatients(mcuID int, dateFrom, dateTo string) ([]PatientDetail, error) {
rows, err := db.DB.Query(`
SELECT
mc.Mcu_CheckinoutPreregisterID,
mp.Mcu_PatientName,
DATE_FORMAT(mc.Mcu_CheckinoutDate, '%Y-%m-%d'),
DATE_FORMAT(mc.Mcu_CheckinoutInTime, '%H:%i'),
COALESCE(DATE_FORMAT(mc.Mcu_CheckinoutOutTime, '%H:%i'), ''),
IF(mc.Mcu_CheckinoutOutTime IS NOT NULL, 1, 0),
rs.station_name,
IF(
(sp.Mcu_StationProgressSource = 'lab' AND sp.Mcu_StationProgressReceiveAt IS NOT NULL AND DATE(sp.Mcu_StationProgressReceiveAt) <= mc.Mcu_CheckinoutDate)
OR (sp.Mcu_StationProgressSource = 'nonlab' AND sp.Mcu_StationProgressDoneAt IS NOT NULL AND DATE(sp.Mcu_StationProgressDoneAt) <= mc.Mcu_CheckinoutDate),
1, 0),
COALESCE(DATE_FORMAT(
CASE
WHEN sp.Mcu_StationProgressSource = 'lab'
THEN COALESCE(sp.Mcu_StationProgressSamplingAt, sp.Mcu_StationProgressProcessAt, sp.Mcu_StationProgressReceiveAt)
WHEN sp.Mcu_StationProgressSource = 'nonlab'
THEN COALESCE(sp.Mcu_StationProgressProcessAt, sp.Mcu_StationProgressDoneAt)
ELSE NULL
END,
'%d/%m/%Y %H:%i'), ''),
COALESCE(DATE_FORMAT(
IF(sp.Mcu_StationProgressSource = 'lab', sp.Mcu_StationProgressReceiveAt, sp.Mcu_StationProgressDoneAt),
'%d/%m/%Y %H:%i'), '')
FROM mcu_checkinout mc
JOIN mcu_patient mp ON mp.Mcu_PatientPreregisterID = mc.Mcu_CheckinoutPreregisterID
JOIN mcu_patient_required_station rs ON
rs.preregister_id = mc.Mcu_CheckinoutPreregisterID
AND rs.mcu_id = ?
LEFT JOIN mcu_station_progress sp ON
sp.Mcu_StationProgressPreregisterID = mc.Mcu_CheckinoutPreregisterID
AND sp.Mcu_StationProgressStationID = rs.sample_station_id
AND sp.Mcu_StationProgressMcuID = ?
AND sp.Mcu_StationProgressCheckinDate = (
SELECT MAX(sp2.Mcu_StationProgressCheckinDate)
FROM mcu_station_progress sp2
WHERE sp2.Mcu_StationProgressPreregisterID = mc.Mcu_CheckinoutPreregisterID
AND sp2.Mcu_StationProgressStationID = rs.sample_station_id
AND sp2.Mcu_StationProgressMcuID = ?
AND sp2.Mcu_StationProgressCheckinDate <= mc.Mcu_CheckinoutDate
)
WHERE mc.Mcu_CheckinoutMcuID = ?
AND mc.Mcu_CheckinoutDate BETWEEN ? AND ?
AND mc.Mcu_CheckinoutIsActive = 'Y'
ORDER BY mc.Mcu_CheckinoutDate DESC, mc.Mcu_CheckinoutInTime DESC, rs.station_name
`, mcuID, mcuID, mcuID, mcuID, dateFrom, dateTo)
if err != nil {
return nil, err
}
defer rows.Close()
var patients []PatientDetail
keyIndex := map[patientKey]int{}
for rows.Next() {
var pid int
var name, date, inTime, outTime, stationName, processAt, doneAt string
var hasOutInt, doneInt int
if err := rows.Scan(&pid, &name, &date, &inTime, &outTime, &hasOutInt, &stationName, &doneInt, &processAt, &doneAt); err != nil {
continue
}
k := patientKey{ID: pid, Date: date}
idx, ok := keyIndex[k]
if !ok {
p := PatientDetail{
ID: pid,
Name: name,
Date: date,
InTime: inTime,
OutTime: outTime,
HasOut: hasOutInt == 1,
}
keyIndex[k] = len(patients)
patients = append(patients, p)
idx = len(patients) - 1
}
done := doneInt == 1
patients[idx].Stations = append(patients[idx].Stations, PatientStationStatus{
Station: stationName,
Done: done,
ProcessAt: processAt,
DoneAt: doneAt,
})
if done {
patients[idx].DoneCount++
}
}
return patients, nil
}

View File

@@ -0,0 +1,12 @@
package dashboard
import "github.com/go-chi/chi/v5"
func Routes(r chi.Router) {
r.Get("/", Index)
r.Get("/stream", SSEStream) // SSE endpoint
r.Get("/kpi", KPI)
r.Get("/stations", Stations)
r.Get("/arrivals", Arrivals)
r.Get("/patients", Patients)
}

View File

@@ -0,0 +1,99 @@
package dashboard
import (
"bytes"
"fmt"
"net/http"
"strings"
"time"
)
type pollState struct {
kpiHash string
stationsHash string
arrivalsHash string
}
func formatSSE(event, html string) string {
data := strings.ReplaceAll(strings.TrimSpace(html), "\n", " ")
return fmt.Sprintf("event: %s\ndata: %s\n\n", event, data)
}
func renderPartial(name string, data interface{}) string {
t := parse("templates/dashboard/partials/" + name + ".html")
var buf bytes.Buffer
t.ExecuteTemplate(&buf, name, data)
return buf.String()
}
func SSEStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // penting untuk nginx reverse proxy
project, _ := GetProject(activeMcuID(r))
if project.McuID == 0 {
return
}
availableDates, _ := GetCheckinDates(project.McuID)
mode, dateFrom, dateTo := activeDateRange(r, project, availableDates)
isLive := mode == "daily" && dateFrom == time.Now().Format("2006-01-02")
var prev pollState
pushKPI := func(force bool) {
kpi, _ := GetKPI(project.McuID, dateFrom, dateTo)
key := fmt.Sprintf("%d|%d|%d", kpi.TotalStaff, kpi.CheckedIn, kpi.CheckedOut)
if force || key != prev.kpiHash {
fmt.Fprint(w, formatSSE("kpi", renderPartial("kpi", kpi)))
prev.kpiHash = key
}
}
pushStations := func(force bool) {
rows, _ := GetStations(project.McuID, dateFrom, dateTo)
key := fmt.Sprintf("%v", rows)
if force || key != prev.stationsHash {
fmt.Fprint(w, formatSSE("stations", renderPartial("stations", StationsPartial{Rows: rows, IsLive: isLive})))
prev.stationsHash = key
}
}
pushArrivals := func(force bool) {
rows, _ := GetArrivals(project.McuID, dateFrom, dateTo, 8)
key := fmt.Sprintf("%v", rows)
if force || key != prev.arrivalsHash {
fmt.Fprint(w, formatSSE("arrivals", renderPartial("arrivals", ArrivalsPartial{Rows: rows, IsLive: isLive})))
prev.arrivalsHash = key
}
}
// Kirim data langsung saat connect (force=true)
pushKPI(true)
pushStations(true)
pushArrivals(true)
flusher.Flush()
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
pushKPI(false)
pushStations(false)
pushArrivals(false)
flusher.Flush()
case <-r.Context().Done():
// Browser disconnect — goroutine ini langsung berhenti
return
}
}
}

View File

@@ -0,0 +1,74 @@
package progress
import (
"cpone-dashboard/menu/auth"
"cpone-dashboard/menu/projects"
"html/template"
"net/http"
)
var tmpl *template.Template
var basePath string
func SetTemplates(t *template.Template) { tmpl = t }
func SetBasePath(p string) { basePath = p }
type pageData struct {
Username string
CurrentProject projects.ProjectItem
Search string
Status string
Rows []ProgressRow
FilteredRows []ProgressRow
Summary ProgressSummary
ValidatedPct int
PublishedPct int
}
func Index(w http.ResponseWriter, r *http.Request) {
username := auth.Username(r)
mcuID := auth.SelectedProjectID(r)
if mcuID == 0 {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
project, ok, err := projects.GetUserProject(username, mcuID)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
if !ok {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
rows, err := GetProgressRows(mcuID)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
summary := BuildProgressSummary(rows)
search := r.URL.Query().Get("search")
status := r.URL.Query().Get("status")
filteredRows := FilterProgressRows(rows, search, status)
t := tmpl
if t == nil {
http.Error(w, "template not ready", http.StatusInternalServerError)
return
}
if err := t.ExecuteTemplate(w, "base", pageData{
Username: username,
CurrentProject: project,
Search: search,
Status: status,
Rows: rows,
FilteredRows: filteredRows,
Summary: summary,
ValidatedPct: Pct(summary.Validated, summary.Total),
PublishedPct: Pct(summary.Published, summary.Total),
}); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,120 @@
package progress
import (
"cpone-dashboard/db"
"strings"
)
type ProgressRow struct {
PreregisterID int
NIP string
Name string
Posisi string
ResumeStatus string
Validated string
Published string
}
type ProgressSummary struct {
Total int
Validated int
Published int
}
func GetProgressRows(mcuID int) ([]ProgressRow, error) {
rows, err := db.DB.Query(`
SELECT
mp.Mcu_PatientPreregisterID,
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '-') AS nip,
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '-') AS name,
COALESCE(
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
'-'
) AS posisi,
COALESCE(rs.Mcu_PatientResumeStatusStatus, '') AS resume_status,
COALESCE(rs.Mcu_PatientResumeStatusValidated, 'N') AS validated,
COALESCE(rs.Mcu_PatientResumeStatusPublished, 'N') AS published
FROM mcu_patient mp
LEFT JOIN mcu_patient_resume_status rs
ON rs.Mcu_PatientResumeStatusPreregisterID = mp.Mcu_PatientPreregisterID
AND rs.Mcu_PatientResumeStatusMcuID = ?
WHERE mp.Mcu_PatientMcuID = ?
AND mp.Mcu_PatientIsActive = 'Y'
ORDER BY
rs.Mcu_PatientResumeStatusValidated DESC,
mp.Mcu_PatientName ASC
`, mcuID, mcuID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ProgressRow
for rows.Next() {
var r ProgressRow
if err := rows.Scan(&r.PreregisterID, &r.NIP, &r.Name, &r.Posisi, &r.ResumeStatus, &r.Validated, &r.Published); err != nil {
continue
}
result = append(result, r)
}
return result, nil
}
func BuildProgressSummary(rows []ProgressRow) ProgressSummary {
s := ProgressSummary{Total: len(rows)}
for _, r := range rows {
if r.Validated == "Y" {
s.Validated++
}
if r.Published == "Y" {
s.Published++
}
}
return s
}
func FilterProgressRows(rows []ProgressRow, search, status string) []ProgressRow {
search = strings.ToLower(strings.TrimSpace(search))
status = strings.TrimSpace(status)
if search == "" && status == "" {
return rows
}
out := make([]ProgressRow, 0, len(rows))
for _, r := range rows {
switch status {
case "validated":
if r.Validated != "Y" {
continue
}
case "published":
if r.Published != "Y" {
continue
}
case "not_validated":
if r.Validated == "Y" {
continue
}
case "not_published":
if r.Published == "Y" {
continue
}
}
if search != "" {
hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi)
if !strings.Contains(hay, search) {
continue
}
}
out = append(out, r)
}
return out
}
func Pct(num, total int) int {
if total == 0 {
return 0
}
return num * 100 / total
}

View File

@@ -0,0 +1,7 @@
package progress
import "github.com/go-chi/chi/v5"
func Routes(r chi.Router) {
r.Get("/", Index)
}

View File

@@ -0,0 +1,71 @@
package projects
import (
"cpone-dashboard/menu/auth"
"embed"
"html/template"
"log"
"net/http"
"strconv"
)
var tmplFS *embed.FS
var basePath string
func SetTemplateFS(fs *embed.FS) { tmplFS = fs }
func SetBasePath(p string) { basePath = p }
type pageData struct {
Username string
Projects []ProjectItem
CurrentProject *ProjectItem
}
func Index(w http.ResponseWriter, r *http.Request) {
username := auth.Username(r)
selectedID := auth.SelectedProjectID(r)
items, err := GetUserProjects(username)
if err != nil {
log.Printf("[projects] GetUserProjects error: %v", err)
http.Error(w, "query error", http.StatusInternalServerError)
return
}
b := func(path string) string { return basePath + path }
t := template.Must(template.New("").Funcs(template.FuncMap{"b": b}).ParseFS(tmplFS, "templates/projects/index.html"))
var current *ProjectItem
if selectedID > 0 {
if item, ok, _ := GetUserProject(username, selectedID); ok {
current = &item
}
}
if err := t.ExecuteTemplate(w, "projects", pageData{
Username: username,
Projects: items,
CurrentProject: current,
}); err != nil {
log.Printf("[projects] template error: %v", err)
}
}
func Select(w http.ResponseWriter, r *http.Request) {
username := auth.Username(r)
mcuID, _ := strconv.Atoi(r.URL.Query().Get("mcu_id"))
if mcuID <= 0 {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
if _, ok, err := GetUserProject(username, mcuID); err != nil {
log.Printf("[projects] GetUserProject error: %v", err)
http.Error(w, "query error", http.StatusInternalServerError)
return
} else if !ok {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
auth.SetSelectedProject(w, mcuID)
http.Redirect(w, r, basePath+"/dashboard", http.StatusSeeOther)
}

View File

@@ -0,0 +1,93 @@
package projects
import (
"cpone-dashboard/db"
"cpone-dashboard/menu/auth"
"net/http"
)
type ProjectItem struct {
McuID int
Label string
CorporateName string
Number string
StartDate string
EndDate string
TotalParticipant int
}
func HasAccess(username string, mcuID int) (bool, error) {
var count int
err := db.DB.QueryRow(`
SELECT COUNT(*)
FROM dashboard_user_project up
JOIN dashboard_user u ON u.User_ID = up.UserProj_UserID
WHERE u.User_Username = ?
AND up.UserProj_McuID = ?
AND up.UserProj_IsActive = 'Y'
`, username, mcuID).Scan(&count)
return count > 0, err
}
func GetUserProjects(username string) ([]ProjectItem, error) {
rows, err := db.DB.Query(`
SELECT
p.Mcu_ProjectMcuID,
COALESCE(p.Mcu_ProjectLabel, ''),
COALESCE(p.Mcu_ProjectCorporateName, ''),
COALESCE(p.Mcu_ProjectNumber, ''),
COALESCE(DATE_FORMAT(p.Mcu_ProjectStartDate, '%d/%m/%Y'), ''),
COALESCE(DATE_FORMAT(p.Mcu_ProjectEndDate, '%d/%m/%Y'), ''),
p.Mcu_ProjectTotalParticipant
FROM dashboard_user_project up
JOIN dashboard_user u ON u.User_ID = up.UserProj_UserID
JOIN mcu_project p ON p.Mcu_ProjectMcuID = up.UserProj_McuID
WHERE u.User_Username = ?
AND up.UserProj_IsActive = 'Y'
AND p.Mcu_ProjectIsActive = 'Y'
ORDER BY p.Mcu_ProjectStartDate DESC
`, username)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ProjectItem
for rows.Next() {
var item ProjectItem
if err := rows.Scan(
&item.McuID,
&item.Label,
&item.CorporateName,
&item.Number,
&item.StartDate,
&item.EndDate,
&item.TotalParticipant,
); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func GetUserProject(username string, mcuID int) (ProjectItem, bool, error) {
items, err := GetUserProjects(username)
if err != nil {
return ProjectItem{}, false, err
}
for _, item := range items {
if item.McuID == mcuID {
return item, true, nil
}
}
return ProjectItem{}, false, nil
}
func ResolveCurrentProject(username string, r *http.Request) (ProjectItem, bool, error) {
selectedID := auth.SelectedProjectID(r)
if selectedID == 0 {
return ProjectItem{}, false, nil
}
return GetUserProject(username, selectedID)
}

View File

@@ -0,0 +1,8 @@
package projects
import "github.com/go-chi/chi/v5"
func Routes(r chi.Router) {
r.Get("/", Index)
r.Get("/select", Select)
}

View File

@@ -0,0 +1,74 @@
package result
import (
"cpone-dashboard/menu/auth"
"cpone-dashboard/menu/projects"
"html/template"
"net/http"
)
var tmpl *template.Template
var pdfBaseURL string
var basePath string
func SetTemplates(t *template.Template) { tmpl = t }
func SetPDFBaseURL(u string) { pdfBaseURL = u }
func SetBasePath(p string) { basePath = p }
type pageData struct {
Username string
CurrentProject projects.ProjectItem
Search string
Filter string
Rows []ResultRow
FilteredRows []ResultRow
Summary ResultSummary
PDFBaseURL string
}
func Index(w http.ResponseWriter, r *http.Request) {
username := auth.Username(r)
mcuID := auth.SelectedProjectID(r)
if mcuID == 0 {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
project, ok, err := projects.GetUserProject(username, mcuID)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
if !ok {
http.Redirect(w, r, basePath+"/projects", http.StatusSeeOther)
return
}
rows, err := GetResultRows(mcuID)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
summary := BuildResultSummary(rows)
search := r.URL.Query().Get("search")
filter := r.URL.Query().Get("filter")
filteredRows := FilterResultRows(rows, search, filter)
t := tmpl
if t == nil {
http.Error(w, "template not ready", http.StatusInternalServerError)
return
}
if err := t.ExecuteTemplate(w, "base", pageData{
Username: username,
CurrentProject: project,
Search: search,
Filter: filter,
Rows: rows,
FilteredRows: filteredRows,
Summary: summary,
PDFBaseURL: pdfBaseURL,
}); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,101 @@
package result
import (
"cpone-dashboard/db"
"strings"
)
type ResultRow struct {
NIP string
Name string
Posisi string
FileUrl string
ReportDate string
}
type ResultSummary struct {
Total int
HasPDF int
}
func GetResultRows(mcuID int) ([]ResultRow, error) {
rows, err := db.DB.Query(`
SELECT
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '-') AS nip,
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '-') AS name,
COALESCE(
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
'-'
) AS posisi,
COALESCE(p.Published_McuDasboardFileUrl, '') AS file_url,
CASE
WHEN p.Published_McuDasboardFileUrl IS NOT NULL
AND p.Published_McuDasboardFileUrl != ''
THEN COALESCE(CAST(p.Published_McuDasboardLastUpdated AS CHAR), '')
ELSE ''
END AS report_date
FROM mcu_patient mp
LEFT JOIN published_mcu_dashboard_sync p
ON p.Published_McuDasboardT_OrderHeaderID = mp.Mcu_PatientOrderID
WHERE mp.Mcu_PatientMcuID = ?
AND mp.Mcu_PatientIsActive = 'Y'
ORDER BY
(p.Published_McuDasboardFileUrl IS NOT NULL AND p.Published_McuDasboardFileUrl != '') DESC,
mp.Mcu_PatientName ASC
`, mcuID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ResultRow
for rows.Next() {
var r ResultRow
if err := rows.Scan(&r.NIP, &r.Name, &r.Posisi, &r.FileUrl, &r.ReportDate); err != nil {
continue
}
result = append(result, r)
}
return result, rows.Err()
}
func BuildResultSummary(rows []ResultRow) ResultSummary {
s := ResultSummary{Total: len(rows)}
for _, r := range rows {
if r.FileUrl != "" {
s.HasPDF++
}
}
return s
}
func FilterResultRows(rows []ResultRow, search, filter string) []ResultRow {
search = strings.ToLower(strings.TrimSpace(search))
filter = strings.TrimSpace(filter)
if search == "" && filter == "" {
return rows
}
out := make([]ResultRow, 0, len(rows))
for _, r := range rows {
switch filter {
case "has_pdf":
if r.FileUrl == "" {
continue
}
case "no_pdf":
if r.FileUrl != "" {
continue
}
}
if search != "" {
hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi)
if !strings.Contains(hay, search) {
continue
}
}
out = append(out, r)
}
return out
}

View File

@@ -0,0 +1,7 @@
package result
import "github.com/go-chi/chi/v5"
func Routes(r chi.Router) {
r.Get("/", Index)
}

View File

@@ -0,0 +1,149 @@
# Demo Scripts — MCU PROJECT DEMO 2026
Script untuk generate dummy data dan simulasi live MCU berjalan, khusus keperluan demo ke client.
---
## Struktur
```
scripts/
├── demo_seed.py # Generate + jalankan dummy data (1500 pasien, historis + hari ini)
├── demo_live.sh # Simulasi aktivitas MCU berjalan secara real-time
├── demo_cleanup.sql # Hapus semua data demo
└── README.md
```
---
## Quick Start
### 1. Seed data awal (jalankan sekali sebelum demo)
```bash
# Di laptop lokal
python3 scripts/demo_seed.py > /tmp/demo_seed.sql
# Upload + jalankan di server
scp /tmp/demo_seed.sql one@devcpone.aplikasi.web.id:/tmp/
ssh one@devcpone.aplikasi.web.id "mysql -u admin -pSasone\!102938 cpone_dashboard < /tmp/demo_seed.sql"
```
Alternatif one-liner:
```bash
python3 scripts/demo_seed.py | ssh one@devcpone.aplikasi.web.id "mysql -u admin -pSasone\!102938 cpone_dashboard"
```
### 2. Jalankan live simulation
```bash
ssh one@devcpone.aplikasi.web.id
# Kecepatan normal (8 detik per aksi)
/home/one/demo_live.sh
# Lebih cepat untuk demo dinamis (3 detik per aksi)
/home/one/demo_live.sh 3
# Lebih lambat (15 detik per aksi)
/home/one/demo_live.sh 15
# Hentikan: Ctrl+C
```
### 3. Reset data (setelah demo selesai)
```bash
ssh one@devcpone.aplikasi.web.id "mysql -u admin -pSasone\!102938 cpone_dashboard < /tmp/demo_cleanup.sql"
```
---
## Data yang Di-seed
| | Detail |
|---|---|
| **Project** | MCU PROJECT DEMO 2026 — PT DEMO CORPORATION |
| **MCU ID** | 9999 |
| **Periode** | 20 April 2026 10 Mei 2026 |
| **Total pasien** | 1.500 (75/hari selama 20 hari) |
| **Preregister ID range** | 900001 901500 |
### Distribusi skenario
| Tanggal | Pasien | Skenario |
|---------|--------|----------|
| 20 Apr 28 Apr | 1 675 | Sudah selesai semua. Di antara ini, setiap kelipatan 15 (patient ke-15, 30, …) melakukan **2-day check-in**: sebagian station hari 1, sisa station hari 2. |
| 29 Apr | 676 750 | Sudah check-in, nonlab station selesai, **lab station masih processing**. |
| 30 Apr (hari ini) | 751 825 | 751810: sudah check-in pagi, **partial station** (sedang berjalan). 811825: **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 2027 (600 pasien) |
| Resume published ✅ | Apr 2025 (375 pasien) |
| PDF tersedia (View PDF) | Pasien 180 (`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" (811825) 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 2027
5. **Result** — tombol "View PDF" aktif untuk pasien 180 + 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.

View File

@@ -0,0 +1,19 @@
-- demo_cleanup.sql — Remove all demo data (MCU ID=9999)
-- Usage: mysql -u admin -pSasone!102938 cpone_dashboard < demo_cleanup.sql
SET FOREIGN_KEY_CHECKS = 0;
DELETE FROM mcu_station_progress WHERE Mcu_StationProgressMcuID = 9999;
DELETE FROM mcu_checkinout WHERE Mcu_CheckinoutMcuID = 9999;
DELETE FROM mcu_patient_required_station WHERE mcu_id = 9999;
DELETE FROM mcu_patient_schedule WHERE Mcu_PatientSchedulePreregisterID BETWEEN 900001 AND 901500;
DELETE FROM mcu_patient_resume_status WHERE Mcu_PatientResumeStatusMcuID = 9999;
DELETE FROM published_mcu_dashboard_sync WHERE Published_McuDasboardT_OrderHeaderID BETWEEN 900001 AND 901500;
DELETE FROM kelainan_details WHERE Mgm_McuID = 9999;
DELETE FROM mcu_participant_daily WHERE Mcu_ParticipantDailyMcuID = 9999;
DELETE FROM mcu_patient WHERE Mcu_PatientMcuID = 9999;
DELETE FROM mcu_project WHERE Mcu_ProjectMcuID = 9999;
SET FOREIGN_KEY_CHECKS = 1;
SELECT 'Demo data removed.' AS status;

View File

@@ -0,0 +1,293 @@
#!/usr/bin/env bash
# demo_live.sh — Simulate live ongoing MCU activity for demo
#
# Usage:
# ./demo_live.sh # default: 1 action every 8 seconds
# ./demo_live.sh 3 # 1 action every 3 seconds (faster)
# ./demo_live.sh 15 # 1 action every 15 seconds (slower)
#
# What it simulates (cycles through 4 action types):
# A — New patient check-in for today (April 30, patients 811825)
# 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

View File

@@ -0,0 +1,417 @@
#!/usr/bin/env python3
"""
demo_seed.py — Generate demo data for MCU PROJECT DEMO 2026 (ID=9999)
Usage: python3 demo_seed.py | mysql -u admin -pSasone!102938 cpone_dashboard
Data distribution:
- 1500 patients, 75/day over April 20 May 9, 2026
- April 2028 (days 08, patients 1675): fully completed MCU
- every 15th patient: 2-day checkin
- April 29 (day 9, patients 676750): checked in, most stations done
- a few lab stations still processing
- April 30 (day 10, patients 751825): ongoing today
- 751810 (60 patients): checked in this morning, partial stations
- 811825 (15 patients): not yet arrived
- May 19 (days 1119, patients 8261500): not yet
"""
from datetime import date, datetime, timedelta
# ── constants ──────────────────────────────────────────────────────────────────
MCU_ID = 9999
BASE_ID = 900000 # preregister_id = BASE_ID + i (900001901500)
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 180 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 18 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) # 2255
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 18 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 2028) ──────────────────────────────────────
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 # 175 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 6175: not yet arrived (no checkin, no stations)
# ─ resume status & published ─────────────────────────────────────────────
# validated: April 2027 (days 07)
# published: April 2025 (days 05)
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 07)
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))

View File

@@ -0,0 +1 @@
/* custom styles — keep minimal, prefer tailwind classes */

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,182 @@
{{define "title"}}Abnormal Monitoring — CpOne{{end}}
{{define "header-title"}}Abnormal Monitoring{{end}}
{{define "content"}}
{{$proj := .CurrentProject}}
{{$group := .Group}}
<section class="card p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
</h2>
<p class="mt-0.5 text-sm text-slate-500">
{{$proj.Number}} &bull; {{$proj.CorporateName}} &bull;
<span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <span class="num">{{$proj.EndDate | fmtDate}}</span>
</p>
</div>
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project
</a>
</div>
</section>
<section class="card p-3">
<div class="flex flex-wrap items-center gap-2 text-sm">
<a href="{{b "/abnormal"}}"
class="rounded-xl px-4 py-2 font-semibold transition
{{if eq $group ""}}bg-brand-500 text-white{{else}}border border-brand-500 text-brand-500 hover:bg-brand-50{{end}}">
Semua Kelainan
</a>
{{range .Groups}}
<a href="{{b "/abnormal"}}?group={{. | urlquery}}"
class="rounded-xl px-4 py-2 font-semibold transition
{{if eq . $group}}bg-brand-500 text-white{{else}}border border-brand-500 text-brand-500 hover:bg-brand-50{{end}}">
{{.}}
</a>
{{end}}
</div>
</section>
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<article class="card border-l-4 border-l-brand-400 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Peserta</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta aktif dalam project</p>
</article>
<article class="card border-l-4 border-l-emerald-400 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Normal</p>
<p class="num mt-2 text-3xl font-semibold text-emerald-600">{{.Summary.Normal}}</p>
<p class="mt-1 text-xs text-slate-400">Tanpa temuan kelainan</p>
</article>
<article class="card border-l-4 border-l-red-400 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Abnormal</p>
<p class="num mt-2 text-3xl font-semibold text-red-500">{{.Summary.Abnormal}}</p>
<p class="mt-1 text-xs text-slate-400">
{{if eq $group ""}}Ada temuan kelainan{{else}}Kelainan: {{$group}}{{end}}
</p>
</article>
<article class="card border-l-4 border-l-amber-400 p-4">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Abnormal Rate</p>
<p class="num mt-2 text-3xl font-semibold text-amber-600">{{.Summary.AbnormalRate}}%</p>
<p class="mt-1 text-xs text-slate-400">Persentase dari total peserta</p>
</article>
</section>
<section class="grid gap-5 xl:grid-cols-2">
<article class="card p-5">
<p class="mb-3 text-sm font-semibold text-slate-700">Normal vs Abnormal</p>
<div id="staff-chart" class="h-72 w-full"></div>
</article>
<article class="card p-5">
<p class="mb-3 text-sm font-semibold text-slate-700">Distribusi Kelompok Usia</p>
<div id="age-chart" class="h-72 w-full"></div>
</article>
<article class="card p-5">
<p class="mb-3 text-sm font-semibold text-slate-700">Gender</p>
<div id="gender-chart" class="h-72 w-full"></div>
</article>
<article id="dept-wrap" class="card p-5">
<p class="mb-3 text-sm font-semibold text-slate-700">Departemen</p>
<div id="dept-chart" class="h-72 w-full"></div>
</article>
</section>
<script>
const staffData = {{.StaffJSON}};
const ageData = {{.AgeJSON}};
const genderData = {{.GenderJSON}};
const deptData = {{.DeptJSON}};
const normalColor = '#3b50a0';
const abnormalColor = '#EF4444';
const staffEl = document.getElementById('staff-chart');
const ageEl = document.getElementById('age-chart');
const genderEl = document.getElementById('gender-chart');
const deptEl = document.getElementById('dept-chart');
const deptWrap = document.getElementById('dept-wrap');
if (staffEl && typeof echarts !== 'undefined') {
const staffChart = echarts.init(staffEl);
staffChart.setOption({
color: [normalColor, abnormalColor],
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [{
type: 'pie',
radius: ['45%', '70%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { formatter: '{b}: {c}' },
data: [
{ value: staffData.normal, name: 'Normal' },
{ value: staffData.abnormal, name: 'Abnormal' }
]
}]
});
window.addEventListener('resize', () => staffChart.resize());
}
if (ageEl && ageData && typeof echarts !== 'undefined') {
const ageChart = echarts.init(ageEl);
ageChart.setOption({
color: [abnormalColor],
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 40, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: ageData.labels },
yAxis: { type: 'value' },
series: [{ name: 'Abnormal', type: 'bar', data: ageData.abnormal, barMaxWidth: 48 }]
});
window.addEventListener('resize', () => ageChart.resize());
}
if (genderEl && genderData && typeof echarts !== 'undefined') {
const genderChart = echarts.init(genderEl);
genderChart.setOption({
color: [normalColor, abnormalColor],
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [{
type: 'pie',
radius: ['45%', '70%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { formatter: '{b}: {c}' },
data: genderData.labels.map(function(l, i) {
return { name: l, value: genderData.abnormal[i] };
})
}]
});
window.addEventListener('resize', () => genderChart.resize());
}
if (deptEl && deptData && deptData.labels && deptData.labels.length > 0 && typeof echarts !== 'undefined') {
const deptChart = echarts.init(deptEl);
deptChart.setOption({
color: [normalColor],
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 8, right: 24, top: 8, bottom: 8, containLabel: true },
xAxis: { type: 'value' },
yAxis: {
type: 'category',
data: deptData.labels.slice().reverse(),
axisLabel: { overflow: 'truncate', width: 160 }
},
series: [{
name: 'Abnormal',
type: 'bar',
data: deptData.abnormal.slice().reverse(),
barMaxWidth: 32,
label: { show: true, position: 'right' }
}]
});
window.addEventListener('resize', () => deptChart.resize());
} else if (deptWrap) {
deptWrap.classList.add('hidden');
}
</script>
{{end}}

View File

@@ -0,0 +1,287 @@
{{define "title"}}Arrival Tracking — CpOne{{end}}
{{define "header-title"}}Arrival Tracking{{end}}
{{define "content"}}
{{$proj := .CurrentProject}}
<section class="card p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
</h2>
<p class="mt-0.5 text-sm text-slate-500">
{{$proj.Number}} &bull; {{$proj.CorporateName}} &bull;
<span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <span class="num">{{$proj.EndDate | fmtDate}}</span>
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project
</a>
<form method="get" action="{{b "/arrival"}}" class="flex flex-wrap items-center gap-2">
<input type="hidden" name="search" value="{{.Search}}"/>
<input type="hidden" name="dept" value="{{.Department}}"/>
<label class="text-xs font-medium text-slate-500">Tanggal Check-in</label>
<select name="date"
class="num rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-slate-700 focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-200">
{{range .AvailableDates}}
<option value="{{.}}" {{if eq . $.Date}}selected{{end}}>{{. | fmtDate}}</option>
{{end}}
</select>
<button type="submit" class="rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
Lihat
</button>
</form>
</div>
</div>
</section>
<section class="grid gap-4 sm:grid-cols-3">
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Checked In</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.CheckedIn}}</p>
<p class="mt-1 text-xs text-slate-400">Sudah check-in pada tanggal ini</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Not Check-in Yet</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Pending}}</p>
<p class="mt-1 text-xs text-slate-400">Belum masuk ke area MCU</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Schedule</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta yang dijadwalkan hari ini</p>
</article>
</section>
<section class="grid gap-5 xl:grid-cols-2">
<article class="card p-5">
<div class="mb-3">
<p class="text-sm font-semibold text-slate-700">Check-in Overview</p>
<p class="text-xs text-slate-400">Inner ring: checked-in summary, outer ring: distribution by department / posisi</p>
</div>
<div id="arrival-overview-chart" class="h-72 w-full"></div>
</article>
<article class="card p-5">
<div class="mb-3">
<p class="text-sm font-semibold text-slate-700">Per Station Distribution</p>
<p class="text-xs text-slate-400">Current observed station loads</p>
</div>
<div id="station-distribution-chart" class="h-64 w-full"></div>
</article>
</section>
<section class="card p-4">
<form method="get" action="{{b "/arrival"}}" class="grid gap-3 md:grid-cols-3">
<input type="hidden" name="date" value="{{.Date}}"/>
<div class="md:col-span-2">
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Participant</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Name or Employee ID"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div>
<div>
<label for="dept" class="mb-2 block text-sm font-medium text-slate-600">Department</label>
<select id="dept" name="dept"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Department ""}}selected{{end}}>All Departments</option>
{{range .DepartmentOptions}}
<option value="{{.}}" {{if eq . $.Department}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div class="md:col-span-3 flex justify-end">
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter
</button>
</div>
</form>
</section>
<section class="card overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<div>
<h2 class="text-base font-semibold text-slate-700">Live Arrival List</h2>
<p class="text-xs text-slate-400">Tanggal: {{.Date | fmtDate}}</p>
</div>
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
</div>
<div class="border-b border-slate-100 px-5 py-3">
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 font-medium text-slate-600">Not Performed Yet</span>
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 font-medium text-amber-700">In Progress</span>
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 font-medium text-emerald-700">Performed</span>
</div>
</div>
{{if .FilteredRows}}
<div class="hidden overflow-x-auto md:block">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Time</th>
<th class="px-4 py-3 font-medium">Employee ID</th>
<th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">Department</th>
<th class="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{{range .FilteredRows}}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 num">{{if .InTime}}{{.InTime}}{{else}}-{{end}}</td>
<td class="px-4 py-3 num">{{.NIP}}</td>
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 text-slate-500">{{.Department}}</td>
<td class="px-4 py-3">
{{if .Stations}}
<div class="flex flex-wrap gap-1.5">
{{range .Stations}}
{{if eq .Tone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span>
{{else if eq .Tone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span>
{{else if eq .Tone "danger"}}
<span class="rounded-full border border-rose-200 bg-rose-50 px-2 py-1 text-xs font-medium text-rose-700">{{.Name}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span>
{{end}}
{{end}}
</div>
{{else}}
{{if eq .StatusTone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span>
{{else if eq .StatusTone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Status}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Status}}</span>
{{end}}
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="grid gap-3 p-4 md:hidden">
{{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3">
<p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-1 text-xs text-slate-400">{{if .InTime}}{{.InTime}}{{else}}-{{end}} • {{.NIP}} • {{.Department}}</p>
<div class="mt-2 flex flex-wrap gap-1.5">
{{if .Stations}}
{{range .Stations}}
{{if eq .Tone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Name}}</span>
{{else if eq .Tone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Name}}</span>
{{else if eq .Tone "danger"}}
<span class="rounded-full border border-rose-200 bg-rose-50 px-2 py-1 text-xs font-medium text-rose-700">{{.Name}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Name}}</span>
{{end}}
{{end}}
{{else}}
{{if eq .StatusTone "success"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">{{.Status}}</span>
{{else if eq .StatusTone "warning"}}
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">{{.Status}}</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{.Status}}</span>
{{end}}
{{end}}
</div>
</article>
{{end}}
</div>
{{else}}
<div class="px-5 py-10 text-center text-sm text-slate-400">
Belum ada data arrival pada tanggal ini.
</div>
{{end}}
</section>
<script>
(function() {
const overviewData = {{.OverviewJSON}};
const stationData = {{.DepartmentJSON}};
const deptColors = ['#f59e0b', '#8b5cf6', '#f97316', '#06b6d4', '#ec4899', '#84cc16', '#14b8a6'];
const overviewEl = document.getElementById('arrival-overview-chart');
if (overviewEl && overviewData && typeof echarts !== 'undefined') {
const overviewChart = echarts.init(overviewEl);
const outerData = (overviewData.depts || []).map(function(d) {
return { value: d.value, name: d.name };
});
overviewChart.setOption({
color: ['#3b50a0', '#cbd5e1'].concat(deptColors),
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: {
type: 'scroll',
orient: 'vertical',
right: 0,
top: 'middle',
textStyle: { color: '#64748b', fontSize: 11 },
selectedMode: false,
pageIconColor: '#3b50a0',
pageTextStyle: { color: '#64748b' }
},
series: [
{
name: 'Check-in Summary',
type: 'pie',
radius: ['28%', '45%'],
center: ['38%', '48%'],
label: { color: '#334155', formatter: '{b}' },
data: [
{ value: overviewData.checkedIn || 0, name: 'Checked In' },
{ value: overviewData.pending || 0, name: 'Not Check-in Yet' }
]
},
{
name: 'Dept Detail',
type: 'pie',
radius: ['55%', '72%'],
center: ['38%', '48%'],
label: { show: false },
labelLine: { show: false },
data: outerData
}
]
});
window.addEventListener('resize', () => overviewChart.resize());
}
const stationEl = document.getElementById('station-distribution-chart');
if (stationEl && stationData && typeof echarts !== 'undefined') {
const stationChart = echarts.init(stationEl);
var revLabels = (stationData.labels || []).slice().reverse();
var revCounts = (stationData.counts || []).slice().reverse();
stationChart.setOption({
color: ['#3b50a0'],
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 10, right: 20, top: 10, bottom: 10, containLabel: true },
xAxis: { type: 'value', axisLabel: { color: '#64748b' } },
yAxis: {
type: 'category',
axisLabel: { color: '#64748b' },
data: revLabels
},
series: [
{
name: 'Patients',
type: 'bar',
barWidth: 18,
data: revCounts,
itemStyle: { borderRadius: [0, 6, 6, 0] }
}
]
});
window.addEventListener('resize', () => stationChart.resize());
}
})();
</script>
{{end}}

View File

@@ -0,0 +1,105 @@
{{define "password"}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Ganti Password — CpOne Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Plus Jakarta Sans', 'sans-serif'] },
colors: {
brand: {
50: '#eef0fb',
100: '#dde2f7',
200: '#bbc5ef',
300: '#8f9fe4',
400: '#6677d6',
500: '#3b50a0',
600: '#2d3d7a',
700: '#212d5a',
800: '#161e3c',
900: '#0b0f1e',
}
}
}
}
}
</script>
</head>
<body class="min-h-screen bg-slate-100 font-sans text-slate-800">
<!-- Header -->
<header class="bg-brand-500 text-white">
<div class="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<a href="{{b "/projects"}}" class="shrink-0 rounded-lg bg-white px-3 py-1.5">
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
</a>
<div class="flex items-center gap-2 text-sm">
<span class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold tracking-wide">{{.Username}}</span>
<a href="{{b "/logout"}}" class="rounded-lg px-3 py-1.5 font-medium opacity-75 transition hover:bg-white/15 hover:opacity-100">Logout</a>
</div>
</div>
</header>
<main class="mx-auto w-full max-w-md px-4 py-10 sm:px-6">
<div class="mb-6">
<a href="{{b "/projects"}}" class="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"/>
</svg>
Kembali
</a>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm">
<h1 class="mb-1 text-lg font-semibold">Ganti Password</h1>
<p class="mb-6 text-sm text-slate-500">Masukkan password saat ini untuk verifikasi, lalu isi password baru.</p>
{{if .Error}}
<div class="mb-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{.Error}}
</div>
{{end}}
{{if .Success}}
<div class="mb-5 rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
{{.Success}}
</div>
{{end}}
<form method="POST" action="{{b "/password"}}" class="space-y-4">
<div class="space-y-1.5">
<label for="current_password" class="block text-sm font-medium">Password Saat Ini</label>
<input id="current_password" name="current_password" type="password" required
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"/>
</div>
<div class="space-y-1.5">
<label for="new_password" class="block text-sm font-medium">Password Baru</label>
<input id="new_password" name="new_password" type="password" required minlength="6"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"/>
</div>
<div class="space-y-1.5">
<label for="confirm_password" class="block text-sm font-medium">Konfirmasi Password Baru</label>
<input id="confirm_password" name="confirm_password" type="password" required minlength="6"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"/>
</div>
<button type="submit"
class="w-full rounded-xl bg-brand-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-600 active:bg-brand-700">
Simpan Password Baru
</button>
</form>
</div>
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,306 @@
{{define "title"}}Dashboard — CpOne{{end}}
{{define "header-title"}}MCU Live Dashboard{{end}}
{{define "content"}}
{{$proj := .Project}}
<style>
@keyframes sseFlash {
0% { box-shadow: 0 0 0 0 rgba(59, 80, 160, 0.45); background-color: #eef2ff; }
100% { box-shadow: 0 0 0 0 rgba(59, 80, 160, 0); background-color: transparent; }
}
.sse-updated {
animation: sseFlash 3.8s ease-out;
}
</style>
<!-- SSE wrapper — satu koneksi, semua section dapat update otomatis -->
<div hx-ext="sse" sse-connect="{{b "/dashboard/stream"}}?mode=daily&date={{.DateFrom}}">
<!-- Project Banner -->
<section class="card p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div class="flex items-center gap-2">
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
{{if .IsLive}}
<span class="flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-600">
<span class="h-1.5 w-1.5 rounded-full bg-red-500 animate-pulse"></span> LIVE
</span>
{{end}}
</div>
<h2 class="mt-1 text-lg font-semibold text-slate-900">{{$proj.Label}}</h2>
<p class="mt-0.5 text-sm text-slate-500">
{{$proj.Number}} &bull; {{$proj.CorporateName}} &bull;
<span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <span class="num">{{$proj.EndDate | fmtDate}}</span>
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project
</a>
<form method="get" action="{{b "/dashboard"}}" class="flex flex-wrap items-center gap-2" id="dashboard-filter-form">
<input type="hidden" name="mode" value="daily"/>
<label class="text-xs font-medium text-slate-500">Tanggal Check-in</label>
<select name="date"
id="dashboard-date"
onchange="this.form.submit()"
class="num rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-slate-700
focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-200">
{{if .AvailableDates}}
{{range .AvailableDates}}
<option value="{{.}}" {{if eq . $.DateFrom}}selected{{end}}>{{. | fmtDate}}</option>
{{end}}
{{else}}
<option value="{{.DateFrom}}" selected>{{.DateFrom | fmtDate}}</option>
{{end}}
</select>
<button type="submit"
class="rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
Lihat
</button>
</form>
{{if gt .KPI.InvitedStaff 0}}
<div class="rounded-xl bg-brand-50 px-3 py-2 text-center">
<p class="text-xs text-slate-500">Invited Staff</p>
<p class="num text-sm font-semibold text-brand-600">{{.KPI.InvitedStaff}}</p>
</div>
{{end}}
</div>
</div>
</section>
<!-- KPI Cards — SSE swap -->
<section id="sse-kpi" class="grid gap-4 sm:grid-cols-3"
sse-swap="kpi" hx-swap="innerHTML">
{{range $i := seq 3}}
<div class="card h-28 animate-pulse bg-slate-50"></div>
{{end}}
</section>
<!-- TAT + TAT Chart -->
<section class="grid gap-5 xl:grid-cols-[1fr_2fr]">
<article class="card border-l-4 border-l-brand-500 p-5">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Avg TAT by Hour</p>
<p class="mt-0.5 text-sm font-medium text-slate-600">Check-in → Check-out</p>
</div>
<span class="num rounded-full bg-brand-50 px-3 py-1 text-xs font-semibold text-brand-600">
{{.DateFrom | fmtDate}}
</span>
</div>
{{if gt .TAT.CheckedOut 0}}
<p class="num mt-5 text-4xl font-semibold text-slate-900">
{{div .TAT.AvgMinutes 60}}<span class="text-xl text-slate-400">h</span>
{{mod .TAT.AvgMinutes 60}}<span class="text-xl text-slate-400">m</span>
</p>
<p class="mt-1 text-xs text-slate-400">Average turnaround untuk pasien yang sudah selesai</p>
<div class="mt-4 grid grid-cols-2 gap-2">
<div class="rounded-xl bg-slate-50 px-2 py-2.5 text-center">
<p class="text-xs text-slate-400">Fastest</p>
<p class="num mt-1 text-sm font-semibold text-slate-700">
{{div .TAT.Fastest 60}}h {{mod .TAT.Fastest 60}}m
</p>
</div>
<div class="rounded-xl bg-slate-50 px-2 py-2.5 text-center">
<p class="text-xs text-slate-400">Median</p>
<p class="num mt-1 text-sm font-semibold text-slate-700">
{{div .TAT.Median 60}}h {{mod .TAT.Median 60}}m
</p>
</div>
</div>
{{else}}
<p class="mt-6 text-sm text-slate-400">Belum ada data checkout pada tanggal ini.</p>
{{end}}
</article>
<article class="card p-5">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-sm font-semibold text-slate-700">Average TAT by Hour</h2>
<span class="text-xs text-slate-400">Hourly average across selected date(s)</span>
</div>
<div id="tat-chart" class="h-52 w-full"></div>
</article>
</section>
<!-- Station Status + Arrival List — SSE swap -->
<section class="grid gap-5 xl:grid-cols-3">
<article id="sse-stations" class="card xl:col-span-2 overflow-hidden"
sse-swap="stations" hx-swap="innerHTML">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<h2 class="text-sm font-semibold text-slate-700">Station Status</h2>
<span class="flex items-center gap-1.5 text-xs font-medium text-slate-400 animate-pulse">
<span class="h-1.5 w-1.5 rounded-full bg-slate-300"></span> Connecting...
</span>
</div>
<div class="p-5 text-sm text-slate-400">Memuat data...</div>
</article>
<article id="sse-arrivals" class="card overflow-hidden"
sse-swap="arrivals" hx-swap="innerHTML">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<h2 class="text-sm font-semibold text-slate-700">Arrival List</h2>
<a href="{{b "/arrival"}}" class="text-xs font-medium text-brand-500 hover:text-brand-700">View all</a>
</div>
<div class="p-5 text-sm text-slate-400">Memuat data...</div>
</article>
</section>
<!-- Trend Chart -->
<section>
<article class="card p-5">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-sm font-semibold text-slate-700">Arrival to Verification Trend by Hour</h2>
<span class="num text-xs text-slate-400">
{{.DateFrom | fmtDate}}
</span>
</div>
<div id="trend-chart" class="h-64 w-full"></div>
</article>
</section>
</div><!-- end SSE wrapper -->
<!-- Modal: Semua Pasien -->
<dialog id="patients-modal"
class="w-full max-w-6xl rounded-2xl border border-slate-200 bg-white shadow-2xl p-0 backdrop:bg-slate-900/50"
onclick="if(event.target===this)this.close()">
<div class="flex flex-col max-h-[85vh]">
<!-- Header -->
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4 flex-shrink-0">
<div>
<h2 class="text-base font-semibold text-slate-900">Semua Pasien</h2>
<p id="patients-modal-subtitle" class="mt-0.5 text-xs text-slate-400"></p>
</div>
<button onclick="document.getElementById('patients-modal').close()"
class="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Body -->
<div id="patients-modal-body" class="overflow-auto flex-1 min-h-0">
<div class="flex items-center justify-center py-16 text-slate-400">
<svg class="h-5 w-5 animate-spin mr-2 text-brand-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
Memuat data...
</div>
</div>
</div>
</dialog>
<script>
function fmtDate(s) {
if (!s) return '';
const [y, m, d] = s.split('-');
return d + '/' + m + '/' + y;
}
function openPatientsModal() {
const params = new URLSearchParams(window.location.search);
const modal = document.getElementById('patients-modal');
const body = document.getElementById('patients-modal-body');
const subtitle = document.getElementById('patients-modal-subtitle');
const mode = params.get('mode') || 'daily';
const date = params.get('date') || '';
const dateEnd = params.get('date_end') || '';
if (mode === 'daily') {
subtitle.textContent = date ? 'Tanggal: ' + fmtDate(date) : 'Hari ini';
} else {
subtitle.textContent = 'Periode: ' + fmtDate(date) + (dateEnd ? ' s/d ' + fmtDate(dateEnd) : '');
}
body.innerHTML = '<div class="flex items-center justify-center py-16 text-slate-400"><svg class="h-5 w-5 animate-spin mr-2 text-brand-400" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path></svg>Memuat data...</div>';
modal.showModal();
fetch('{{b "/dashboard/patients"}}?' + params.toString())
.then(r => r.text())
.then(html => { body.innerHTML = html; })
.catch(() => { body.innerHTML = '<p class="p-6 text-sm text-red-500">Gagal memuat data.</p>'; });
}
(function() {
const sseTargets = new Set(['sse-kpi', 'sse-stations', 'sse-arrivals']);
document.body.addEventListener('htmx:afterSwap', function (evt) {
const target = evt.detail && evt.detail.target ? evt.detail.target : null;
if (!target || !target.id || !sseTargets.has(target.id)) return;
// Skip first hydration so highlight means "new/update", not initial render.
if (!target.dataset.sseHydrated) {
target.dataset.sseHydrated = '1';
return;
}
target.classList.remove('sse-updated');
void target.offsetWidth;
target.classList.add('sse-updated');
});
const palette = ['#3b50a0', '#6677d6', '#10b981', '#f59e0b'];
const tatData = {{.TATChart}};
const trendData = {{.TrendChart}};
const tatEl = document.getElementById('tat-chart');
if (tatEl && tatData.labels && tatData.labels.length) {
const tatChart = echarts.init(tatEl);
tatChart.setOption({
color: palette,
tooltip: {
trigger: 'axis',
formatter: p => `${p[0].axisValue}<br/>Avg TAT: <b>${Math.round(p[0].data)} mnt</b>`
},
grid: { left: 50, right: 20, top: 16, bottom: 28 },
xAxis: {
type: 'category', data: tatData.labels,
axisLine: { lineStyle: { color: '#e2e8f0' } }, axisTick: { show: false }
},
yAxis: {
type: 'value', name: 'Mnt',
nameTextStyle: { color: '#94a3b8', fontSize: 11 },
splitLine: { lineStyle: { color: '#f1f5f9' } }
},
series: [{
name: 'Avg TAT', type: 'bar', barWidth: 24, data: tatData.values,
itemStyle: { borderRadius: [6, 6, 0, 0], color: '#3b50a0' }
}]
});
window.addEventListener('resize', () => tatChart.resize());
}
const trendEl = document.getElementById('trend-chart');
if (trendEl && trendData.labels && trendData.labels.length) {
const trendChart = echarts.init(trendEl);
trendChart.setOption({
color: palette,
tooltip: { trigger: 'axis' },
legend: {
data: ['Checked In', 'Checked Out'],
textStyle: { fontSize: 12, color: '#64748b' }, top: 0
},
grid: { left: 40, right: 20, top: 36, bottom: 28 },
xAxis: {
type: 'category', data: trendData.labels,
axisLine: { lineStyle: { color: '#e2e8f0' } }, axisTick: { show: false }
},
yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f1f5f9' } } },
series: [
{ name: 'Checked In', type: 'line', smooth: true, data: trendData.checkedIn, symbolSize: 5 },
{ name: 'Checked Out', type: 'line', smooth: true, data: trendData.checkedOut, symbolSize: 5, lineStyle: { type: 'dashed' } }
]
});
window.addEventListener('resize', () => trendChart.resize());
}
})();
</script>
{{end}}

View File

@@ -0,0 +1,37 @@
{{define "arrivals"}}
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<h2 class="text-sm font-semibold text-slate-700">Arrival List</h2>
<div class="flex items-center gap-3">
{{if .IsLive}}
<span class="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
<span class="live-dot h-1.5 w-1.5 rounded-full bg-emerald-500"></span> Live
</span>
{{end}}
<button onclick="openPatientsModal()"
class="text-xs font-medium text-brand-500 hover:text-brand-700 transition-colors">
Lihat selengkapnya
</button>
</div>
</div>
{{if .Rows}}
<ul class="divide-y divide-slate-50 px-3 py-2">
{{range .Rows}}
<li class="flex items-center gap-3 rounded-xl px-2 py-2.5 transition-colors hover:bg-slate-50">
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-brand-50 text-xs font-bold text-brand-600">
{{.Name | initials}}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-slate-800">{{.Name}}</p>
<p class="text-xs text-slate-400">
<span class="num">{{fmtDateTime .Date .InTime}}</span> &bull; {{.Station | stationShort}}
</p>
</div>
</li>
{{end}}
</ul>
{{else}}
<div class="px-5 py-8 text-center text-sm text-slate-400">
Belum ada arrival pada tanggal ini.
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,43 @@
{{define "kpi"}}
<!-- Total Staff -->
<article class="card border-l-4 border-l-brand-300 p-4">
<div class="flex items-start justify-between">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Staff</p>
<svg class="h-4 w-4 text-brand-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<p class="num mt-3 text-3xl font-semibold text-slate-900">{{.TotalStaff}}</p>
{{if gt .InvitedStaff 0}}
<p class="mt-1 text-xs font-medium text-emerald-600">{{printf "%.1f%%" (pct .TotalStaff .InvitedStaff)}} dari invited</p>
{{else}}
<p class="mt-1 text-xs text-slate-400">Yang benar-benar datang</p>
{{end}}
</article>
<!-- Checked In -->
<article class="card border-l-4 border-l-brand-500 p-4">
<div class="flex items-start justify-between">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">In Progress</p>
<svg class="h-4 w-4 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<p class="num mt-3 text-3xl font-semibold text-slate-900">{{.CheckedIn}}</p>
<p class="mt-1 text-xs text-slate-400">Masih dalam proses</p>
</article>
<!-- Checked Out -->
<article class="card border-l-4 border-l-emerald-500 p-4">
<div class="flex items-start justify-between">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Checked Out</p>
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
</div>
<p class="num mt-3 text-3xl font-semibold text-slate-900">{{.CheckedOut}}</p>
{{if gt .CheckedIn 0}}
<p class="mt-1 text-xs text-slate-400">{{printf "%.1f%%" (pct .CheckedOut .CheckedIn)}} selesai</p>
{{end}}
</article>
{{end}}

View File

@@ -0,0 +1,80 @@
{{define "patients"}}
{{if not .Patients}}
<div class="flex flex-col items-center justify-center py-16 text-slate-400">
<svg class="mb-3 h-10 w-10 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
<p class="text-sm font-medium">Belum ada data pasien pada tanggal ini.</p>
</div>
{{else}}
<div class="divide-y divide-slate-100">
{{range .Patients}}
{{if and (gt .DoneCount 0) (eq .DoneCount (len .Stations))}}
<div class="px-5 py-4 bg-emerald-50/60 transition-colors hover:bg-emerald-50">
{{else}}
<div class="px-5 py-4 hover:bg-slate-50 transition-colors">
{{end}}
<!-- Row atas: nama + waktu -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2.5">
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-brand-50 text-xs font-bold text-brand-600">
{{.Name | initials}}
</div>
<div>
<p class="font-semibold text-slate-800">{{.Name}}</p>
{{if $.IsRange}}<p class="text-xs text-slate-400 num">{{.Date | fmtDate}}</p>{{end}}
</div>
</div>
<div class="flex items-center gap-3 text-xs">
<span class="num text-slate-500">
Masuk: <span class="font-semibold text-slate-700">{{.InTime}}</span>
</span>
{{if .HasOut}}
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 font-semibold text-emerald-700 num">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
Keluar: {{.OutTime}}
</span>
{{else}}
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 font-semibold text-amber-600">
<span class="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse"></span>
Dalam proses
</span>
{{end}}
<span class="text-slate-400">
<span class="font-semibold text-brand-600">{{.DoneCount}}</span>/{{len .Stations}} station selesai
</span>
</div>
</div>
<!-- Row bawah: station badges -->
{{if .Stations}}
<div class="mt-2.5 flex flex-wrap gap-1.5 pl-10">
{{range .Stations}}
{{if .Done}}
<span class="inline-flex max-w-full flex-col gap-0.5 rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-800 cursor-default">
<span class="inline-flex items-center gap-1">
<svg class="h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
{{.Station}}
</span>
<span class="num pl-4 text-[10px] font-semibold text-emerald-700/80">
Proses: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Selesai: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}}
</span>
</span>
{{else}}
<span class="inline-flex max-w-full flex-col gap-0.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500 cursor-default">
<span class="inline-flex items-center gap-1">
<svg class="h-3 w-3 flex-shrink-0 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{.Station}}
</span>
<span class="num pl-4 text-[10px] font-semibold text-slate-400">
Proses: {{if .ProcessAt}}{{.ProcessAt}}{{else}}-{{end}} | Selesai: {{if .DoneAt}}{{.DoneAt}}{{else}}-{{end}}
</span>
</span>
{{end}}
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,53 @@
{{define "stations"}}
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<h2 class="text-sm font-semibold text-slate-700">Station Status</h2>
{{if .IsLive}}
<span class="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
<span class="live-dot h-1.5 w-1.5 rounded-full bg-emerald-500"></span> Live
</span>
{{end}}
</div>
{{if .Rows}}
<div class="p-3">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-xs font-semibold uppercase tracking-wide text-slate-400">
<th class="px-3 py-2">Station</th>
<th class="px-3 py-2 text-right">Sudah</th>
<th class="px-3 py-2 text-right">Belum</th>
<th class="px-3 py-2 text-right">Total</th>
<th class="px-3 py-2 w-40">Progress</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
{{range .Rows}}
<tr class="hover:bg-slate-50 transition-colors">
<td class="px-3 py-2.5 font-medium text-slate-700">
{{.Station | stationShort}}
</td>
<td class="num px-3 py-2.5 text-right font-semibold text-slate-900">{{.Processed}}</td>
<td class="num px-3 py-2.5 text-right text-amber-600">{{.Pending}}</td>
<td class="num px-3 py-2.5 text-right text-slate-400">{{.Total}}</td>
<td class="px-3 py-2.5">
<div class="flex items-center gap-2">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full transition-all"
style="width: {{printf "%.1f" .Pct}}%; background: {{if ge .Pct 90.0}}#10b981{{else if ge .Pct 60.0}}#3b50a0{{else}}#f59e0b{{end}}">
</div>
</div>
<span class="num text-xs font-semibold {{if ge .Pct 90.0}}text-emerald-600{{else if ge .Pct 60.0}}text-brand-600{{else}}text-amber-600{{end}}">
{{printf "%.0f%%" .Pct}}
</span>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="px-5 py-8 text-center text-sm text-slate-400">
Belum ada data station pada tanggal ini.
</div>
{{end}}
{{end}}

View File

@@ -0,0 +1,90 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{{block "title" .}}CpOne Dashboard{{end}}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/sse.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Plus Jakarta Sans', 'sans-serif'],
mono: ['IBM Plex Mono', 'monospace'],
},
colors: {
brand: {
50: '#eef0fb',
100: '#dde2f7',
200: '#bbc5ef',
300: '#8f9fe4',
400: '#6677d6',
500: '#3b50a0',
600: '#2d3d7a',
700: '#212d5a',
800: '#161e3c',
900: '#0b0f1e',
}
}
}
}
}
</script>
<style>
body { font-family: 'Plus Jakarta Sans', sans-serif; }
.num { font-family: 'IBM Plex Mono', monospace; }
.live-dot { animation: pulse-dot 2s infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.card { @apply rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow hover:shadow-md; }
</style>
<link rel="stylesheet" href="{{b "/static/css/custom.css"}}"/>
</head>
<body class="min-h-screen bg-slate-100 text-slate-800">
<!-- Header -->
<header class="bg-brand-500 text-white">
<div class="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<div class="flex items-center gap-4">
<a href="{{b "/dashboard"}}" class="shrink-0 rounded-lg bg-white px-3 py-1.5">
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
</a>
<div>
<p class="text-sm font-semibold leading-tight">{{block "header-title" .}}Dashboard{{end}}</p>
</div>
</div>
<div class="hidden items-center gap-1 text-sm sm:flex">
<nav class="flex items-center gap-1">
<a href="{{b "/dashboard"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Dashboard</a>
<a href="{{b "/arrival"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Arrival</a>
<a href="{{b "/progress"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Progress</a>
<a href="{{b "/abnormal"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Abnormal</a>
<a href="{{b "/result"}}" class="rounded-lg px-3 py-1.5 font-medium transition hover:bg-white/15">Result</a>
</nav>
{{if .Username}}
<div class="ml-3 flex items-center gap-2 border-l border-white/20 pl-3">
<a href="{{b "/password"}}" class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold tracking-wide transition hover:bg-white/25">{{.Username}}</a>
<a href="{{b "/logout"}}" class="rounded-lg px-3 py-1.5 font-medium opacity-75 transition hover:bg-white/15 hover:opacity-100">Logout</a>
</div>
{{end}}
</div>
</div>
</header>
<main class="mx-auto w-full max-w-7xl space-y-5 px-4 py-5 sm:px-6 lg:px-8">
{{block "content" .}}{{end}}
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,129 @@
{{define "login"}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Login — CpOne Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Plus Jakarta Sans', 'sans-serif'] },
colors: {
brand: {
50: '#eef0fb',
100: '#dde2f7',
200: '#bbc5ef',
300: '#8f9fe4',
400: '#6677d6',
500: '#3b50a0',
600: '#2d3d7a',
700: '#212d5a',
800: '#161e3c',
900: '#0b0f1e',
}
}
}
}
}
</script>
</head>
<body class="min-h-screen bg-slate-100 font-sans text-slate-800">
<main class="grid min-h-screen lg:grid-cols-2">
<!-- Left panel -->
<section class="relative hidden overflow-hidden lg:flex lg:flex-col lg:justify-between lg:p-12">
<div class="absolute inset-0 bg-gradient-to-br from-brand-800 via-brand-700 to-brand-500"></div>
<div class="absolute -left-24 -top-16 h-80 w-80 rounded-full bg-white/5 blur-3xl"></div>
<div class="absolute -bottom-20 right-0 h-96 w-96 rounded-full bg-white/5 blur-3xl"></div>
<div class="relative z-10">
<div class="inline-flex rounded-lg bg-white px-4 py-2">
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
</div>
</div>
<div class="relative z-10 space-y-4 text-white">
<p class="inline-flex rounded-full border border-white/25 bg-white/10 px-4 py-1.5 text-sm backdrop-blur">
Corporate MCU Platform
</p>
<h1 class="max-w-md text-3xl font-semibold leading-snug">
Monitor Arrival, Sampling, dan Lab Verification dalam Satu Tempat
</h1>
<p class="max-w-sm text-sm text-brand-200">
Real-time dashboard untuk visibilitas operasional MCU perusahaan.
</p>
</div>
</section>
<!-- Right panel -->
<section class="flex items-center justify-center p-6 sm:p-10">
<div class="w-full max-w-md">
<!-- Logo mobile -->
<div class="mb-8 flex justify-center lg:hidden">
<div class="rounded-xl bg-brand-500 px-5 py-3">
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-10 w-auto brightness-0 invert">
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm sm:p-8">
<div class="mb-7 space-y-1">
<h2 class="text-xl font-semibold">Masuk ke akun Anda</h2>
<p class="text-sm text-slate-500">Gunakan username dan password yang terdaftar.</p>
</div>
{{if .Error}}
<div class="mb-5 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{.Error}}
</div>
{{end}}
<form method="POST" action="{{b "/mcu-login"}}" class="space-y-5">
<div class="space-y-1.5">
<label for="username" class="block text-sm font-medium">Username</label>
<input
id="username"
name="username"
type="text"
required
autocomplete="username"
placeholder="Masukkan username"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
</div>
<div class="space-y-1.5">
<label for="password" class="block text-sm font-medium">Password</label>
<input
id="password"
name="password"
type="password"
required
autocomplete="current-password"
placeholder="Masukkan password"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
/>
</div>
<button
type="submit"
class="w-full rounded-xl bg-brand-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-600 active:bg-brand-700"
>
Masuk
</button>
</form>
</div>
<p class="mt-5 text-center text-xs text-slate-400">CpOne Dashboard &mdash; Laboratorium &amp; Klinik Westerindo</p>
</div>
</section>
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,196 @@
{{define "title"}}Result Progress — CpOne{{end}}
{{define "header-title"}}Result Progress{{end}}
{{define "content"}}
{{$proj := .CurrentProject}}
<section class="card p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
</h2>
<p class="mt-0.5 text-sm text-slate-500">
{{$proj.Number}} &bull; {{$proj.CorporateName}} &bull;
<span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <span class="num">{{$proj.EndDate | fmtDate}}</span>
</p>
</div>
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project
</a>
</div>
</section>
<section class="grid gap-4 sm:grid-cols-3">
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Validated</p>
<p class="num mt-2 text-3xl font-semibold text-emerald-600">{{.Summary.Validated}}</p>
<p class="mt-1 text-xs text-slate-400">Resume sudah divalidasi dokter</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Published</p>
<p class="num mt-2 text-3xl font-semibold text-brand-500">{{.Summary.Published}}</p>
<p class="mt-1 text-xs text-slate-400">Hasil sudah dikirim ke peserta</p>
</article>
</section>
<section class="card p-5">
<h2 class="mb-4 text-sm font-semibold text-slate-700">Resume Progress</h2>
<div class="space-y-4">
<div>
<div class="mb-1.5 flex items-center justify-between text-sm">
<span class="font-medium text-slate-700">Validated</span>
<span class="text-xs text-slate-400">
{{.Summary.Validated}} / {{.Summary.Total}}
<span class="ml-1 font-semibold text-emerald-600">{{.ValidatedPct}}%</span>
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-slate-100">
<div class="h-2 rounded-full bg-emerald-500 transition-all" style="width: {{.ValidatedPct}}%"></div>
</div>
</div>
<div>
<div class="mb-1.5 flex items-center justify-between text-sm">
<span class="font-medium text-slate-700">Published</span>
<span class="text-xs text-slate-400">
{{.Summary.Published}} / {{.Summary.Total}}
<span class="ml-1 font-semibold text-brand-500">{{.PublishedPct}}%</span>
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-slate-100">
<div class="h-2 rounded-full bg-brand-500 transition-all" style="width: {{.PublishedPct}}%"></div>
</div>
</div>
</div>
</section>
<section class="card p-4">
<form method="get" action="{{b "/progress"}}" class="grid gap-3 md:grid-cols-3">
<div class="md:col-span-2">
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div>
<div>
<label for="status" class="mb-2 block text-sm font-medium text-slate-600">Status</label>
<select id="status" name="status"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Status ""}}selected{{end}}>All</option>
<option value="validated" {{if eq .Status "validated"}}selected{{end}}>Validated</option>
<option value="published" {{if eq .Status "published"}}selected{{end}}>Published</option>
<option value="not_validated" {{if eq .Status "not_validated"}}selected{{end}}>Not Validated</option>
<option value="not_published" {{if eq .Status "not_published"}}selected{{end}}>Not Published</option>
</select>
</div>
<div class="md:col-span-3 flex justify-end">
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter
</button>
</div>
</form>
</section>
<section class="card overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<div>
<h2 class="text-base font-semibold text-slate-700">Patient Resume List</h2>
<p class="text-xs text-slate-400">Data dari mcu_patient_resume_status</p>
</div>
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
</div>
<div class="border-b border-slate-100 px-5 py-3">
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 font-medium text-emerald-700">Validated</span>
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 font-medium text-slate-600">Not Validated</span>
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 font-medium text-brand-500">Published</span>
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 font-medium text-slate-600">Not Published</span>
</div>
</div>
{{if .FilteredRows}}
<div class="hidden overflow-x-auto md:block">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">NIP</th>
<th class="px-4 py-3 font-medium">Posisi</th>
<th class="px-4 py-3 font-medium">Resume Status</th>
<th class="px-4 py-3 font-medium">Validated</th>
<th class="px-4 py-3 font-medium">Published</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{{range .FilteredRows}}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 num text-slate-500">{{.NIP}}</td>
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
<td class="px-4 py-3">
{{if .ResumeStatus}}
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
{{else}}
<span class="text-xs text-slate-400"></span>
{{end}}
</td>
<td class="px-4 py-3">
{{if eq .Validated "Y"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Y</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">N</span>
{{end}}
</td>
<td class="px-4 py-3">
{{if eq .Published "Y"}}
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Y</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">N</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="grid gap-3 p-4 md:hidden">
{{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p>
</div>
{{if .ResumeStatus}}
<span class="rounded-full border border-slate-200 bg-slate-50 px-2 py-1 text-xs font-medium text-slate-600">{{.ResumeStatus}}</span>
{{end}}
</div>
<div class="mt-2 flex gap-2">
{{if eq .Validated "Y"}}
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-1 text-xs font-medium text-emerald-700">Validated</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">Not Validated</span>
{{end}}
{{if eq .Published "Y"}}
<span class="rounded-full border border-brand-400/40 bg-brand-50 px-2 py-1 text-xs font-medium text-brand-500">Published</span>
{{else}}
<span class="rounded-full border border-slate-200 bg-slate-100 px-2 py-1 text-xs font-medium text-slate-500">Not Published</span>
{{end}}
</div>
</article>
{{end}}
</div>
{{else}}
<div class="px-5 py-10 text-center text-sm text-slate-400">
Belum ada data resume untuk project ini.
</div>
{{end}}
</section>
{{end}}

View File

@@ -0,0 +1,119 @@
{{define "projects"}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Pilih Project — CpOne Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Plus Jakarta Sans', 'sans-serif'] },
colors: {
brand: {
50: '#eef0fb',
100: '#dde2f7',
200: '#bbc5ef',
300: '#8f9fe4',
400: '#6677d6',
500: '#3b50a0',
600: '#2d3d7a',
700: '#212d5a',
800: '#161e3c',
900: '#0b0f1e',
}
}
}
}
}
</script>
</head>
<body class="min-h-screen bg-slate-100 font-sans text-slate-800">
<!-- Header -->
<header class="bg-brand-500 text-white">
<div class="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<a href="{{b "/projects"}}" class="shrink-0 rounded-lg bg-white px-3 py-1.5">
<img src="{{b "/static/img/logo.png"}}" alt="Logo" class="h-8 w-auto">
</a>
<div class="flex items-center gap-2 text-sm">
<span class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold tracking-wide">{{.Username}}</span>
<a href="{{b "/logout"}}" class="rounded-lg px-3 py-1.5 font-medium opacity-75 transition hover:bg-white/15 hover:opacity-100">Logout</a>
</div>
</div>
</header>
<main class="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-6">
<h1 class="text-xl font-semibold text-slate-800">Pilih Project</h1>
<p class="mt-1 text-sm text-slate-500">Pilih salah satu project MCU untuk mulai monitoring.</p>
</div>
{{if .CurrentProject}}
<div class="mb-6 rounded-2xl border border-brand-100 bg-brand-50 px-4 py-3 text-sm text-brand-700">
Project aktif saat ini:
<span class="font-semibold">{{if .CurrentProject.Label}}{{.CurrentProject.Label}}{{else}}MCU #{{.CurrentProject.McuID}}{{end}}</span>
<span class="text-brand-500"></span>
<a href="{{b "/dashboard"}}" class="font-semibold underline decoration-brand-200 underline-offset-2">Buka dashboard</a>
</div>
{{end}}
{{if not .Projects}}
<div class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-300 bg-white py-16 text-center">
<svg class="mb-3 h-10 w-10 text-slate-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"/>
</svg>
<p class="text-sm font-medium text-slate-500">Belum ada project yang di-assign</p>
<p class="mt-1 text-xs text-slate-400">Hubungi administrator untuk mendapatkan akses.</p>
</div>
{{else}}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{{range .Projects}}
<a href="{{b "/projects/select"}}?mcu_id={{.McuID}}"
class="group flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-brand-400 hover:shadow-md">
<!-- Company + badge -->
<div class="flex items-start justify-between gap-2">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-400">{{.Number}}</p>
<p class="mt-0.5 font-semibold leading-snug text-slate-800">{{.CorporateName}}</p>
</div>
<span class="shrink-0 rounded-full bg-brand-50 px-2.5 py-0.5 text-xs font-semibold text-brand-600">
MCU #{{.McuID}}
</span>
</div>
<!-- Label -->
{{if .Label}}
<p class="mt-2 text-sm text-slate-500">{{.Label}}</p>
{{end}}
<!-- Footer info -->
<div class="mt-4 flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-slate-400">
<span>{{.StartDate}}{{if .EndDate}} {{.EndDate}}{{end}}</span>
<span class="font-medium text-slate-500">{{.TotalParticipant}} peserta</span>
</div>
<!-- Hover cta -->
<div class="mt-3 flex items-center gap-1 text-xs font-semibold text-brand-500 opacity-0 transition group-hover:opacity-100">
Buka Dashboard
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/>
</svg>
</div>
</a>
{{end}}
</div>
{{end}}
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,185 @@
{{define "title"}}Result Reports — CpOne{{end}}
{{define "header-title"}}Consolidated Result Reports{{end}}
{{define "content"}}
{{$proj := .CurrentProject}}
{{/* Section 1: Current project */}}
<section class="card p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
</h2>
<p class="mt-0.5 text-sm text-slate-500">
{{$proj.Number}} &bull; {{$proj.CorporateName}} &bull;
<span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <span class="num">{{$proj.EndDate | fmtDate}}</span>
</p>
</div>
<a href="{{b "/projects"}}" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project
</a>
</div>
</section>
{{/* Section 2: Summary cards */}}
<section class="grid gap-4 sm:grid-cols-2">
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Has PDF</p>
<p class="num mt-2 text-3xl font-semibold text-brand-500">{{.Summary.HasPDF}}</p>
<p class="mt-1 text-xs text-slate-400">Laporan hasil sudah tersedia</p>
</article>
</section>
{{/* Section 3: Filter form */}}
<section class="card p-4">
<form method="get" action="{{b "/result"}}" class="grid gap-3 md:grid-cols-3">
<div class="md:col-span-2">
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div>
<div>
<label for="filter" class="mb-2 block text-sm font-medium text-slate-600">Status PDF</label>
<select id="filter" name="filter"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Filter ""}}selected{{end}}>All</option>
<option value="has_pdf" {{if eq .Filter "has_pdf"}}selected{{end}}>Has PDF</option>
<option value="no_pdf" {{if eq .Filter "no_pdf"}}selected{{end}}>No PDF</option>
</select>
</div>
<div class="md:col-span-3 flex justify-end">
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter
</button>
</div>
</form>
</section>
{{/* Section 4: Patient list */}}
<section class="card overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<div>
<h2 class="text-base font-semibold text-slate-700">Patient Result List</h2>
<p class="text-xs text-slate-400">Data dari published_mcu_dashboard_sync</p>
</div>
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
</div>
{{if .FilteredRows}}
<div class="hidden overflow-x-auto md:block">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Employee ID</th>
<th class="px-4 py-3 font-medium">Patient</th>
<th class="px-4 py-3 font-medium">Department</th>
<th class="px-4 py-3 font-medium">Report Date</th>
<th class="px-4 py-3 font-medium">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{{range .FilteredRows}}
<tr class="hover:bg-slate-50">
<td class="num px-4 py-3 text-slate-500">{{.NIP}}</td>
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
<td class="num px-4 py-3 text-slate-500">
{{if .ReportDate}}{{.ReportDate | fmtDate}}{{else}}<span class="text-slate-300"></span>{{end}}
</td>
<td class="px-4 py-3">
{{if .FileUrl}}
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')"
class="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF
</button>
{{else}}
<span class="text-xs text-slate-300"></span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="grid gap-3 p-4 md:hidden">
{{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p>
{{if .ReportDate}}<p class="mt-0.5 num text-xs text-slate-400">{{.ReportDate | fmtDate}}</p>{{end}}
</div>
{{if .FileUrl}}
<button onclick="openPDFModal('{{$.PDFBaseURL}}{{.FileUrl}}', '{{.Name}}')"
class="shrink-0 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF
</button>
{{else}}
<span class="text-xs text-slate-300">No PDF</span>
{{end}}
</div>
</article>
{{end}}
</div>
{{else}}
<div class="px-5 py-10 text-center text-sm text-slate-400">
Belum ada data untuk project ini.
</div>
{{end}}
</section>
{{/* PDF Modal */}}
<div id="pdf-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onclick="if(event.target===this)closePDFModal()">
<div class="flex h-full w-full max-w-5xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl">
<div class="flex shrink-0 items-center justify-between border-b border-slate-200 px-5 py-3">
<p id="pdf-modal-title" class="truncate text-sm font-semibold text-slate-700"></p>
<div class="ml-4 flex shrink-0 items-center gap-3">
<a id="pdf-modal-link" href="#" target="_blank"
class="text-xs font-medium text-brand-500 hover:underline">
Buka di tab baru ↗
</a>
<button onclick="closePDFModal()"
class="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<iframe id="pdf-modal-frame" src="" class="min-h-0 flex-1 w-full" frameborder="0"></iframe>
</div>
</div>
<script>
function openPDFModal(url, name) {
document.getElementById('pdf-modal-frame').src = url;
document.getElementById('pdf-modal-title').textContent = name;
document.getElementById('pdf-modal-link').href = url;
const modal = document.getElementById('pdf-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
document.body.style.overflow = 'hidden';
}
function closePDFModal() {
const modal = document.getElementById('pdf-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
document.getElementById('pdf-modal-frame').src = '';
document.body.style.overflow = '';
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closePDFModal();
});
</script>
{{end}}

View File

@@ -0,0 +1,583 @@
# Result Menu Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implementasi halaman `/result` yang menampilkan daftar peserta MCU beserta tombol View PDF, mengambil data dari `cpone_dashboard.mcu_patient` dan `cpone_dashboard.published_mcu_dashboard_sync`.
**Architecture:** Handler mengikuti pola `progress` — fetch semua rows, build summary, apply filter di memory, render template. PDF base URL dikonfigurasi via `.env` sebagai `PDF_BASE_URL`, disimpan di package-level var `pdfBaseURL` di package `result`.
**Tech Stack:** Go 1.21, Chi router, Go HTML templates (embed), Tailwind via CDN, MySQL 8 (`cpone_dashboard`).
**Working directory untuk semua command:** `/Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard`
---
## File Map
| File | Status | Tanggung jawab |
|------|--------|----------------|
| `config/config.go` | **Modify** | Tambah field `PDFBaseURL string` |
| `.env` | **Modify** | Tambah `PDF_BASE_URL=http://devcpone.aplikasi.web.id/dashboard-files/` |
| `.env.example` | **Modify** | Tambah `PDF_BASE_URL=http://your-server/dashboard-files/` |
| `menu/result/query.go` | **Rewrite** | Types + query + filter/summary helpers |
| `menu/result/handler.go` | **Rewrite** | pageData, pdfBaseURL var, Index handler |
| `main.go` | **Modify** | Wire `cfg.PDFBaseURL` ke `result.SetPDFBaseURL` |
| `templates/result/index.html` | **Rewrite** | Full page template |
---
## Task 1: Tambah PDFBaseURL ke config
**Files:**
- Modify: `config/config.go`
- Modify: `.env`
- Modify: `.env.example`
- [ ] **Step 1: Update config/config.go**
Tambah field `PDFBaseURL` ke struct dan `Load()`:
```go
package config
import (
"log"
"os"
"github.com/joho/godotenv"
)
type Config struct {
AppPort string
DBDSN string
AuthSecret string
PDFBaseURL string
}
func Load() *Config {
if err := godotenv.Load(); err != nil {
log.Println("no .env file, reading from environment")
}
return &Config{
AppPort: getEnv("APP_PORT", "8080"),
DBDSN: getEnv("DB_DSN", ""),
AuthSecret: getEnv("AUTH_SECRET", "cpone-change-this-secret"),
PDFBaseURL: getEnv("PDF_BASE_URL", ""),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
```
- [ ] **Step 2: Tambah ke .env**
Buka `.env`, tambah baris di bawah `AUTH_SECRET`:
```
PDF_BASE_URL=http://devcpone.aplikasi.web.id/dashboard-files/
```
- [ ] **Step 3: Tambah ke .env.example**
Buka `.env.example`, tambah baris di bawah `AUTH_SECRET`:
```
PDF_BASE_URL=http://your-server/dashboard-files/
```
- [ ] **Step 4: Verifikasi build**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
go build ./...
```
Expected: tidak ada error output.
- [ ] **Step 5: Commit**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
git add config/config.go .env .env.example
git commit -m "config: add PDF_BASE_URL env var"
```
---
## Task 2: Implementasi query.go
**Files:**
- Rewrite: `menu/result/query.go`
- [ ] **Step 1: Tulis query.go lengkap**
```go
package result
import (
"cpone-dashboard/db"
"strings"
)
type ResultRow struct {
NIP string
Name string
Posisi string
FileUrl string
ReportDate string
}
type ResultSummary struct {
Total int
HasPDF int
}
func GetResultRows(mcuID int) ([]ResultRow, error) {
rows, err := db.DB.Query(`
SELECT
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '-') AS nip,
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '-') AS name,
COALESCE(
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
'-'
) AS posisi,
COALESCE(p.Published_McuDasboardFileUrl, '') AS file_url,
CASE
WHEN p.Published_McuDasboardFileUrl IS NOT NULL
AND p.Published_McuDasboardFileUrl != ''
THEN COALESCE(CAST(p.Published_McuDasboardLastUpdated AS CHAR), '')
ELSE ''
END AS report_date
FROM mcu_patient mp
LEFT JOIN published_mcu_dashboard_sync p
ON p.Published_McuDasboardT_OrderHeaderID = mp.Mcu_PatientOrderID
WHERE mp.Mcu_PatientMcuID = ?
AND mp.Mcu_PatientIsActive = 'Y'
ORDER BY
(p.Published_McuDasboardFileUrl IS NOT NULL AND p.Published_McuDasboardFileUrl != '') DESC,
mp.Mcu_PatientName ASC
`, mcuID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ResultRow
for rows.Next() {
var r ResultRow
if err := rows.Scan(&r.NIP, &r.Name, &r.Posisi, &r.FileUrl, &r.ReportDate); err != nil {
continue
}
result = append(result, r)
}
return result, rows.Err()
}
func BuildResultSummary(rows []ResultRow) ResultSummary {
s := ResultSummary{Total: len(rows)}
for _, r := range rows {
if r.FileUrl != "" {
s.HasPDF++
}
}
return s
}
func FilterResultRows(rows []ResultRow, search, filter string) []ResultRow {
search = strings.ToLower(strings.TrimSpace(search))
filter = strings.TrimSpace(filter)
if search == "" && filter == "" {
return rows
}
out := make([]ResultRow, 0, len(rows))
for _, r := range rows {
switch filter {
case "has_pdf":
if r.FileUrl == "" {
continue
}
case "no_pdf":
if r.FileUrl != "" {
continue
}
}
if search != "" {
hay := strings.ToLower(r.Name + " " + r.NIP + " " + r.Posisi)
if !strings.Contains(hay, search) {
continue
}
}
out = append(out, r)
}
return out
}
```
- [ ] **Step 2: Verifikasi build**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
go build ./...
```
Expected: tidak ada error.
- [ ] **Step 3: Commit**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
git add menu/result/query.go
git commit -m "result: implement query, summary, and filter helpers"
```
---
## Task 3: Implementasi handler.go
**Files:**
- Rewrite: `menu/result/handler.go`
- [ ] **Step 1: Tulis handler.go lengkap**
```go
package result
import (
"cpone-dashboard/menu/auth"
"cpone-dashboard/menu/projects"
"html/template"
"net/http"
)
var tmpl *template.Template
var pdfBaseURL string
func SetTemplates(t *template.Template) { tmpl = t }
func SetPDFBaseURL(u string) { pdfBaseURL = u }
type pageData struct {
Username string
CurrentProject projects.ProjectItem
Search string
Filter string
Rows []ResultRow
FilteredRows []ResultRow
Summary ResultSummary
PDFBaseURL string
}
func Index(w http.ResponseWriter, r *http.Request) {
username := auth.Username(r)
mcuID := auth.SelectedProjectID(r)
if mcuID == 0 {
http.Redirect(w, r, "/projects", http.StatusSeeOther)
return
}
project, ok, err := projects.GetUserProject(username, mcuID)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
if !ok {
http.Redirect(w, r, "/projects", http.StatusSeeOther)
return
}
rows, err := GetResultRows(mcuID)
if err != nil {
http.Error(w, "query error", http.StatusInternalServerError)
return
}
summary := BuildResultSummary(rows)
search := r.URL.Query().Get("search")
filter := r.URL.Query().Get("filter")
filteredRows := FilterResultRows(rows, search, filter)
t := tmpl
if t == nil {
http.Error(w, "template not ready", http.StatusInternalServerError)
return
}
if err := t.ExecuteTemplate(w, "base", pageData{
Username: username,
CurrentProject: project,
Search: search,
Filter: filter,
Rows: rows,
FilteredRows: filteredRows,
Summary: summary,
PDFBaseURL: pdfBaseURL,
}); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
}
}
```
- [ ] **Step 2: Verifikasi build**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
go build ./...
```
Expected: tidak ada error.
- [ ] **Step 3: Commit**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
git add menu/result/handler.go
git commit -m "result: implement Index handler with filter and summary"
```
---
## Task 4: Wire PDFBaseURL di main.go
**Files:**
- Modify: `main.go` — tambah `result.SetPDFBaseURL(cfg.PDFBaseURL)` setelah `result.SetTemplates(...)`
- [ ] **Step 1: Temukan baris result.SetTemplates di main.go**
Cari baris (sekitar line 254):
```go
result.SetTemplates(newPageTmpl("templates/result/index.html"))
```
- [ ] **Step 2: Tambah SetPDFBaseURL tepat setelahnya**
```go
result.SetTemplates(newPageTmpl("templates/result/index.html"))
result.SetPDFBaseURL(cfg.PDFBaseURL)
```
- [ ] **Step 3: Verifikasi build**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
go build ./...
```
Expected: tidak ada error.
- [ ] **Step 4: Commit**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
git add main.go
git commit -m "main: wire PDF_BASE_URL into result handler"
```
---
## Task 5: Implementasi template
**Files:**
- Rewrite: `templates/result/index.html`
- [ ] **Step 1: Tulis template lengkap**
```html
{{define "title"}}Result Reports — CpOne{{end}}
{{define "header-title"}}Consolidated Result Reports{{end}}
{{define "content"}}
{{$proj := .CurrentProject}}
{{/* Section 1: Current project */}}
<section class="card p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-brand-500">Ongoing Project</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{{if $proj.Label}}{{$proj.Label}}{{else}}MCU #{{$proj.McuID}}{{end}}
</h2>
<p class="mt-0.5 text-sm text-slate-500">
{{$proj.Number}} &bull; {{$proj.CorporateName}} &bull;
<span class="num">{{$proj.StartDate | fmtDate}}</span> &ndash; <span class="num">{{$proj.EndDate | fmtDate}}</span>
</p>
</div>
<a href="/projects" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-brand-400 hover:text-brand-600">
Ganti project
</a>
</div>
</section>
{{/* Section 2: Summary cards */}}
<section class="grid gap-4 sm:grid-cols-2">
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Total Patients</p>
<p class="num mt-2 text-3xl font-semibold text-slate-900">{{.Summary.Total}}</p>
<p class="mt-1 text-xs text-slate-400">Peserta dalam project ini</p>
</article>
<article class="card p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Has PDF</p>
<p class="num mt-2 text-3xl font-semibold text-brand-500">{{.Summary.HasPDF}}</p>
<p class="mt-1 text-xs text-slate-400">Laporan hasil sudah tersedia</p>
</article>
</section>
{{/* Section 3: Filter form */}}
<section class="card p-4">
<form method="get" action="/result" class="grid gap-3 md:grid-cols-3">
<div class="md:col-span-2">
<label for="search" class="mb-2 block text-sm font-medium text-slate-600">Search Patient</label>
<input id="search" name="search" value="{{.Search}}" type="text" placeholder="Nama atau Employee ID"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200"/>
</div>
<div>
<label for="filter" class="mb-2 block text-sm font-medium text-slate-600">Status PDF</label>
<select id="filter" name="filter"
class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-brand-400 focus:ring-2 focus:ring-brand-200">
<option value="" {{if eq .Filter ""}}selected{{end}}>All</option>
<option value="has_pdf" {{if eq .Filter "has_pdf"}}selected{{end}}>Has PDF</option>
<option value="no_pdf" {{if eq .Filter "no_pdf"}}selected{{end}}>No PDF</option>
</select>
</div>
<div class="md:col-span-3 flex justify-end">
<button type="submit" class="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-600">
Filter
</button>
</div>
</form>
</section>
{{/* Section 4: Patient list */}}
<section class="card overflow-hidden">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
<div>
<h2 class="text-base font-semibold text-slate-700">Patient Result List</h2>
<p class="text-xs text-slate-400">Data dari published_mcu_dashboard_sync</p>
</div>
<span class="text-xs font-medium text-slate-400">{{len .FilteredRows}} ditampilkan</span>
</div>
{{if .FilteredRows}}
<div class="hidden overflow-x-auto md:block">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Employee ID</th>
<th class="px-4 py-3 font-medium">Patient</th>
<th class="px-4 py-3 font-medium">Department</th>
<th class="px-4 py-3 font-medium">Report Date</th>
<th class="px-4 py-3 font-medium">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{{range .FilteredRows}}
<tr class="hover:bg-slate-50">
<td class="num px-4 py-3 text-slate-500">{{.NIP}}</td>
<td class="px-4 py-3 font-medium text-slate-700">{{.Name}}</td>
<td class="px-4 py-3 text-slate-500">{{.Posisi}}</td>
<td class="num px-4 py-3 text-slate-500">
{{if .ReportDate}}{{.ReportDate | fmtDate}}{{else}}<span class="text-slate-300"></span>{{end}}
</td>
<td class="px-4 py-3">
{{if .FileUrl}}
<a href="{{$.PDFBaseURL}}{{.FileUrl}}" target="_blank"
class="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF
</a>
{{else}}
<span class="text-xs text-slate-300"></span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="grid gap-3 p-4 md:hidden">
{{range .FilteredRows}}
<article class="rounded-xl border border-slate-200 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-700">{{.Name}}</p>
<p class="mt-0.5 text-xs text-slate-400">{{.NIP}} &bull; {{.Posisi}}</p>
{{if .ReportDate}}<p class="mt-0.5 num text-xs text-slate-400">{{.ReportDate | fmtDate}}</p>{{end}}
</div>
{{if .FileUrl}}
<a href="{{$.PDFBaseURL}}{{.FileUrl}}" target="_blank"
class="shrink-0 rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-600">
View PDF
</a>
{{else}}
<span class="text-xs text-slate-300">No PDF</span>
{{end}}
</div>
</article>
{{end}}
</div>
{{else}}
<div class="px-5 py-10 text-center text-sm text-slate-400">
Belum ada data untuk project ini.
</div>
{{end}}
</section>
{{end}}
```
- [ ] **Step 2: Build dan cek tidak ada syntax error template**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
go build ./...
```
Expected: tidak ada error.
- [ ] **Step 3: Commit**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
git add templates/result/index.html
git commit -m "result: implement full page template"
```
---
## Task 6: Manual verification
- [ ] **Step 1: Pastikan SSH tunnel aktif, lalu jalankan app**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
make start
```
Expected: `server running on :8080`
- [ ] **Step 2: Buka browser, login, pilih project**
Navigasi ke `http://localhost:8080/result`. Harus tampil:
- Header "Consolidated Result Reports"
- Summary cards: Total Patients dan Has PDF (sesuai data di DB)
- Table daftar pasien
- [ ] **Step 3: Verifikasi tombol View PDF**
Row pertama (NIP sesuai data) harus ada tombol "View PDF" berwarna brand-500. Klik — harus buka PDF di tab baru dengan URL `http://devcpone.aplikasi.web.id/dashboard-files/2024/09/R2409170003_resume_individu.pdf`.
- [ ] **Step 4: Verifikasi filter**
Pilih filter "Has PDF" → hanya 1 row tampil. Pilih "No PDF" → semua row selain 1 tampil. Ketik nama di search → filter berjalan.
- [ ] **Step 5: Deploy ke server**
```bash
cd /Users/fajrihardhitamurti/REPO_CPONE_DASHBOARD/cpone-dashboard
make deploy
```
Expected: `deployed to one@devcpone.aplikasi.web.id:/home/one/project/cpone-dashboard`

View File

@@ -0,0 +1,107 @@
# MCU Dashboard (cpone-dashboard) — Design Spec
Date: 2026-04-27
## Overview
Dashboard live monitoring MCU (Medical Check-Up) untuk laboratorium klinik CpOne. Menampilkan data real-time dari kegiatan MCU korporat: KPI harian, TAT, status station, arrival tracking, progress pemeriksaan, abnormal monitoring, dan laporan hasil.
## Data Architecture
```
cpone (main DB, Server A)
↓ inject/ETL (proyek terpisah)
cpone_dashboard (Server A)
↓ MySQL replication (otomatis)
cpone_dashboard (Server B — production)
↓ dibaca oleh
Go Dashboard App (Server B)
```
Dashboard app **hanya** konek ke `cpone_dashboard` lokal. Zero dependency ke `cpone`.
## Tech Stack
- **Backend**: Go 1.21, framework Chi (router lightweight)
- **Frontend**: Go HTML templates (embed ke binary), HTMX via CDN, ECharts via CDN, Tailwind via CDN
- **Database**: MySQL 8.0, single connection ke `cpone_dashboard`
- **Build**: Cross-compile di Mac (`GOOS=linux GOARCH=amd64`), deploy binary ke server
- **Primary color**: `#3b50a0`
## Pages
1. **Login** — autentikasi user
2. **Dashboard** — KPI cards, TAT harian, station status table, arrival list, trend chart (HTMX polling tiap 10s)
3. **Arrival Tracking** — daftar peserta check-in
4. **Observation Progress** — progress per station pemeriksaan
5. **Abnormal Monitoring** — hasil pemeriksaan dengan flag abnormal
6. **Result Reports** — laporan hasil konsolidasi per peserta
## Folder Structure
```
cpone-dashboard/
├── main.go
├── go.mod
├── go.sum
├── .env ← DB DSN, port, dll
├── .env.example
├── Makefile ← make build, make deploy
├── config/
│ └── config.go
├── db/
│ └── db.go ← single connection ke cpone_dashboard
├── menu/
│ ├── dashboard/
│ │ ├── handler.go
│ │ ├── query.go
│ │ └── route.go
│ ├── arrival/
│ │ ├── handler.go
│ │ ├── query.go
│ │ └── route.go
│ ├── progress/
│ │ ├── handler.go
│ │ ├── query.go
│ │ └── route.go
│ ├── abnormal/
│ │ ├── handler.go
│ │ ├── query.go
│ │ └── route.go
│ └── result/
│ ├── handler.go
│ ├── query.go
│ └── route.go
├── templates/
│ ├── layout/
│ │ └── base.html
│ ├── dashboard/
│ │ ├── index.html
│ │ └── partials/
│ │ ├── kpi.html
│ │ ├── stations.html
│ │ └── arrivals.html
│ ├── arrival/
│ │ └── index.html
│ ├── progress/
│ │ └── index.html
│ ├── abnormal/
│ │ └── index.html
│ └── result/
│ └── index.html
└── static/
└── css/
└── custom.css
```
## Deploy Flow
```bash
make deploy
# = GOOS=linux GOARCH=amd64 go build -o cpone-dashboard .
# + scp cpone-dashboard one@devcpone.aplikasi.web.id:/home/one/project/cpone-dashboard/
# + ssh ... restart process
```
## Out of Scope
- Inject/ETL dari `cpone` ke `cpone_dashboard` (proyek terpisah)
- MySQL replication setup
- Multi-tenancy / multi-server config

View File

@@ -0,0 +1,114 @@
# Result Menu — Design Spec
Date: 2026-04-30
## Overview
Halaman `/result` menampilkan daftar peserta MCU beserta tombol "View PDF" untuk membuka laporan hasil konsolidasi. Data diambil sepenuhnya dari `cpone_dashboard` (zero dependency ke `cpone`).
## Data Sources
Semua tabel ada di `cpone_dashboard`:
- `mcu_patient` — data peserta (NIP, nama, posisi/dept, order ID)
- `published_mcu_dashboard_sync` — file URL PDF per peserta
Join key: `mcu_patient.Mcu_PatientOrderID = published_mcu_dashboard_sync.Published_McuDasboardT_OrderHeaderID`
## Config / Env
Tambah key baru ke `.env`, `.env.example`, dan `config/config.go`:
```
PDF_BASE_URL=http://devcpone.aplikasi.web.id/dashboard-files/
```
Field `PDFBaseURL string` ditambah ke struct `Config`. Nilai ini di-passing ke `result` handler saat setup di `main.go`.
## Backend — `menu/result/`
### query.go
```go
type ResultRow struct {
NIP string
Name string
Posisi string
FileUrl string // kosong jika belum ada PDF
ReportDate string // Published_McuDasboardLastUpdated
}
type ResultSummary struct {
Total int
HasPDF int
}
```
Query:
```sql
SELECT
COALESCE(NULLIF(TRIM(mp.Mcu_PatientNIP), ''), '-') AS nip,
COALESCE(NULLIF(TRIM(mp.Mcu_PatientName), ''), '-') AS name,
COALESCE(
NULLIF(TRIM(mp.Mcu_PatientDepartment), ''),
NULLIF(TRIM(mp.Mcu_PatientDivision), ''),
NULLIF(TRIM(mp.Mcu_PatientPosisi), ''),
'-'
) AS posisi,
COALESCE(p.Published_McuDasboardFileUrl, '') AS file_url,
CASE
WHEN p.Published_McuDasboardFileUrl IS NOT NULL AND p.Published_McuDasboardFileUrl != ''
THEN COALESCE(p.Published_McuDasboardLastUpdated, '')
ELSE ''
END AS report_date
FROM mcu_patient mp
LEFT JOIN published_mcu_dashboard_sync p
ON p.Published_McuDasboardT_OrderHeaderID = mp.Mcu_PatientOrderID
WHERE mp.Mcu_PatientMcuID = ?
AND mp.Mcu_PatientIsActive = 'Y'
ORDER BY
(p.Published_McuDasboardFileUrl IS NOT NULL AND p.Published_McuDasboardFileUrl != '') DESC,
mp.Mcu_PatientName ASC
```
Helper functions:
- `BuildResultSummary(rows []ResultRow) ResultSummary`
- `FilterResultRows(rows []ResultRow, search, filter string) []ResultRow`
- filter values: `""` (all), `"has_pdf"`, `"no_pdf"`
### handler.go
`pageData` struct:
```go
type pageData struct {
Username string
CurrentProject projects.ProjectItem
Search string
Filter string
Rows []ResultRow
FilteredRows []ResultRow
Summary ResultSummary
PDFBaseURL string
}
```
Handler `Index` mengikuti pola progress: redirect ke `/projects` jika belum pilih project, fetch rows, build summary, apply filter, render template.
`PDFBaseURL` di-inject saat `SetTemplates` — tambah fungsi `SetPDFBaseURL(url string)` di package result.
### route.go
Tidak berubah — sudah ada `r.Get("/", Index)`.
## Template — `templates/result/index.html`
**Section 1 — Current project card**
Sama persis dengan progress/arrival: nama project, nomor, tombol "Ganti project".
**Section 2 — Summary cards (2 cards)**
- Total Patients
- Has PDF (count `FileUrl != ""`)
**Section 3 — Filter form**
- Search input (nama atau NIP)
- Dropdown: All / Has PDF / No PDF
- Tombol Filter
**Section 4 — Patient list**
- Desktop: table dengan kolom NIP, Nama, Posisi/Dept, Report Date, Action
- Mobile: card stack
- Action: tombol `View PDF` (buka tab baru) jika `FileUrl != ""`, teks `—` jika kosong
- PDF full URL: `PDFBaseURL + FileUrl`
## Referensi Visual
`/PLAN/draft-cpone/06-result.html` — warna dan layout mengikuti color scheme brand yang ada (`brand-500`, `slate-*`), bukan warna `diagnos-*` dari draft.