From 228d4d14df4dbfe30e4745d3d60568ec02e462f6 Mon Sep 17 00:00:00 2001 From: ongtrieuhau Date: Mon, 26 Oct 2026 00:10:07 -0700 Subject: [PATCH] feat: DH-HIS Web Giao Ban Go v1.0.1 (Favicon, Exe icon, System Tray, Gitea auto-update) --- .gitignore | 6 + README.md | 115 ++++ app.ico | Bin 0 -> 30568 bytes build.sh | 30 + config.json | 44 ++ config/config.go | 264 ++++++++ data/issues.json | 28 + db/db.go | 156 +++++ go.mod | 23 + go.sum | 55 ++ handlers/admin.go | 218 ++++++ handlers/api.go | 681 +++++++++++++++++++ handlers/issues.go | 287 ++++++++ logger/logger.go | 121 ++++ main.go | 228 +++++++ public/admin.html | 345 ++++++++++ public/admin.js | 446 ++++++++++++ public/app.js | 1544 ++++++++++++++++++++++++++++++++++++++++++ public/favicon.ico | Bin 0 -> 30568 bytes public/icon.png | Bin 0 -> 2856 bytes public/index.html | 907 +++++++++++++++++++++++++ public/styles.css | 54 ++ restart.sh | 5 + start.sh | 28 + status.sh | 14 + stop.sh | 18 + tray/app.ico | Bin 0 -> 30568 bytes tray/icon.go | 13 + tray/icon.png | Bin 0 -> 1272 bytes tray/tray_other.go | 23 + tray/tray_windows.go | 71 ++ updater/updater.go | 190 ++++++ 32 files changed, 5914 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.ico create mode 100755 build.sh create mode 100644 config.json create mode 100644 config/config.go create mode 100644 data/issues.json create mode 100644 db/db.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers/admin.go create mode 100644 handlers/api.go create mode 100644 handlers/issues.go create mode 100644 logger/logger.go create mode 100644 main.go create mode 100644 public/admin.html create mode 100644 public/admin.js create mode 100644 public/app.js create mode 100644 public/favicon.ico create mode 100644 public/icon.png create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100755 restart.sh create mode 100755 start.sh create mode 100755 status.sh create mode 100755 stop.sh create mode 100644 tray/app.ico create mode 100644 tray/icon.go create mode 100644 tray/icon.png create mode 100644 tray/tray_other.go create mode 100644 tray/tray_windows.go create mode 100644 updater/updater.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9ab09a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +web-giao-ban +web-giao-ban.exe +*.syso +*.rc +logs/ +uploads/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..91e7449 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# DH-HIS Quản Lý Giao Ban Hằng Ngày (Golang Standalone) + +Ứng dụng độc lập (Single Binary Executable) viết bằng **Golang**, nhúng toàn bộ giao diện Web, cơ chế quản trị kết nối CSDL PostgreSQL bảo mật mã hóa AES-256, khay hệ thống (System Tray) cho Windows, và chức năng tự động kiểm tra/cập nhật phiên bản từ Gitea Package/Release. + +--- + +## 🌟 1. Điểm nổi bật & Tính năng chính + +1. **Chỉ 1 file duy nhất (Single Binary Executable)**: + - Toàn bộ giao diện HTML, CSS, JavaScript, icon được nhúng trực tiếp vào file nhị phân qua `//go:embed`. + - Không cần cài Node.js, không cần thư mục `node_modules` hay web server bên ngoài. + - Dung lượng nhẹ: ~7.3 MB trên Windows (`.exe`) và ~6.8 MB trên Linux. +2. **Khay hệ thống (System Tray)**: + - Chạy trên Windows có icon khay hệ thống, không hiện cửa sổ console đen (`-H windowsgui`). + - Menu gồm: + - 🌐 Mở Báo Cáo Giao Ban + - ⚙️ Quản Trị & Cấu Hình + - 📜 Xem Nhật Ký / Log App + - 🔄 Kiểm Tra Cập Nhật + - ❌ Thoát Ứng Dụng + - Chạy trên Linux Server tự động chuyển sang chế độ Daemon / Service nền ổn định. +3. **Trang Quản Trị & Cấu Hình Kết Nối (`/admin`)**: + - Tùy chỉnh Cổng Web (Port), đường dẫn quản trị. + - Cấu hình kết nối PostgreSQL: Host, Port, User, Password, CSDL mặc định. + - **Mã hóa AES-256-GCM**: Mật khẩu và Token được tự động mã hóa dạng `ENC(...)` khi lưu vào `config.json`, không lộ bản rõ. + - Nút **"Thử Kết Nối (Test Connection)"** kiểm tra thông suốt CSDL ngay trên web. + - Bảng quản lý danh sách CSDL các bệnh viện hiển thị trên dropdown. +4. **Nhật Ký Thời Gian Thực (Live Log Viewer)**: + - Ring buffer 500 dòng log gần nhất lưu trong bộ nhớ và ghi ra `logs/app.log`. + - Xem log trực tiếp trên trang quản trị có lọc theo cấp độ (INFO, WARN, ERROR, SQL) và tự động cuộn (Auto-scroll). +5. **Tự động kiểm tra & cập nhật phiên bản (Gitea Self-Update)**: + - Kết nối với Gitea Releases / Generic Package Registry qua Token cá nhân. + - Phát hiện phiên bản mới, xem ghi chú phát hành (*Release Notes*), tải và tự thay thế file binary đang chạy (*in-place binary replacement*). + +--- + +## 🚀 2. Hướng dẫn biên dịch (Build) + +Chạy script biên dịch có sẵn: +```bash +./build.sh +``` +Kết quả sinh ra 2 file: +- `web-giao-ban`: File thực thi standalone cho Linux. +- `web-giao-ban.exe`: File thực thi standalone cho Windows (chỉ cần copy sang máy Windows là click đúp chạy). + +--- + +## 💻 3. Hướng dẫn sử dụng trên Windows + +1. Copy file `web-giao-ban.exe` vào thư mục bất kỳ trên máy Windows (Ví dụ: `D:\DH-HIS\GiaoBan\`). +2. Nhấp đúp vào `web-giao-ban.exe` để chạy. +3. Icon chữ thập y tế màu xanh sẽ xuất hiện ở **Khay hệ thống (System Tray)** góc dưới bên phải màn hình. +4. Nhấp chuột phải vào icon khay hệ thống: + - Bấm **"Mở Báo Cáo Giao Ban"** để mở trình duyệt xem số liệu. + - Bấm **"Quản Trị & Cấu Hình"** để đổi thông số IP máy chủ hoặc CSDL. + - Bấm **"Xem Nhật Ký / Log App"** để kiểm tra log hoạt động. + - Bấm **"Kiểm Tra Cập Nhật"** để cập nhật phần mềm mới từ Gitea. + - Bấm **"Thoát"** để dừng ứng dụng. + +--- + +## 🐧 4. Quản lý dịch vụ trên Linux (Systemd Service) + +- **Xem trạng thái**: `./status.sh` hoặc `sudo systemctl status web-giao-ban` +- **Khởi động**: `./start.sh` hoặc `sudo systemctl start web-giao-ban` +- **Dừng dịch vụ**: `./stop.sh` hoặc `sudo systemctl stop web-giao-ban` +- **Khởi động lại**: `./restart.sh` hoặc `sudo systemctl restart web-giao-ban` + +--- + +## 🔒 5. Cấu trúc file cấu hình `config.json` + +File `config.json` được tự động sinh lần đầu tiên và mã hóa bảo mật: +```json +{ + "server": { + "port": 3000, + "admin_path": "/admin", + "admin_password": "ENC(...)" + }, + "database": { + "host": "192.168.50.79", + "port": 5437, + "user": "postgres", + "password": "ENC(...)", + "default_db": "an_omon_full_04052026", + "databases": [ + { "id": "an_omon_full_04052026", "name": "BVĐK Ô Môn - Cần Thơ (33 GB - Chuẩn khoa phòng)" }, + { "id": "tuan_dbgovap_full", "name": "BVĐK Gò Vấp - TP.HCM (65 GB - Dữ liệu lớn)" }, + { "id": "binh_bvq12_062026052026042026032026022026", "name": "BV Quận 12 - TP.HCM (21 GB)" }, + { "id": "bvq12_697", "name": "BV Quận 12 - Bản 697 (17 GB)" }, + { "id": "an_dalieu_full_05082026", "name": "BV Da Liễu (31 GB)" } + ] + }, + "updater": { + "enabled": true, + "gitea_url": "https://i.dh-his.com", + "owner": "ongtrieuhau", + "repo": "web-giao-ban", + "token": "ENC(...)", + "current_version": "v1.0.0" + } +} +``` + +--- + +## 📦 6. Package & Release trên Gitea + +- **Repository**: [https://i.dh-his.com/ongtrieuhau/web-giao-ban](https://i.dh-his.com/ongtrieuhau/web-giao-ban) +- **Generic Package**: [https://i.dh-his.com/ongtrieuhau/-/packages/generic/web-giao-ban/1.0.1](https://i.dh-his.com/ongtrieuhau/-/packages/generic/web-giao-ban/1.0.1) +- **Release Assets**: [https://i.dh-his.com/ongtrieuhau/web-giao-ban/releases/tag/v1.0.1](https://i.dh-his.com/ongtrieuhau/web-giao-ban/releases/tag/v1.0.1) + - `web-giao-ban.exe` (Windows x64) + - `web-giao-ban-linux-amd64` (Linux x64) diff --git a/app.ico b/app.ico new file mode 100644 index 0000000000000000000000000000000000000000..9ab316461f79540beef7340d76e0f8f4fb6835df GIT binary patch literal 30568 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 Z(EkSu{dVLH8;#*(QI>-r|Nl$1|34$vK2!hz literal 0 HcmV?d00001 diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..7ebcc32 --- /dev/null +++ b/build.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$DIR" + +echo "=======================================================" +echo " Building DH-HIS Web Giao Ban (Go Standalone)" +echo "=======================================================" + +# 1. Build Linux binary +echo "[1/2] Building Linux standalone binary: web-giao-ban..." +go build -ldflags="-s -w" -o web-giao-ban . +chmod +x web-giao-ban +echo " -> OK: $(ls -lh web-giao-ban | awk '{print $9, $5}')" + +# 2. Build Windows .exe binary +echo "[2/2] Building Windows standalone executable: web-giao-ban.exe..." +if [ -f "app.ico" ]; then + echo " * Embedding application icon into Windows PE resource..." + echo '1 ICON "app.ico"' > app.rc + x86_64-w64-mingw32-windres -i app.rc -O coff -o rsrc_windows_amd64.syso +fi +CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 \ + go build -ldflags="-s -w -H windowsgui" -o web-giao-ban.exe . +echo " -> OK: $(ls -lh web-giao-ban.exe | awk '{print $9, $5}')" + +echo "=======================================================" +echo " Build hoàn tất! Có thể copy web-giao-ban.exe sang Windows là chạy được ngay." +echo "=======================================================" diff --git a/config.json b/config.json new file mode 100644 index 0000000..641f6c6 --- /dev/null +++ b/config.json @@ -0,0 +1,44 @@ +{ + "server": { + "port": 3001, + "admin_path": "/admin", + "admin_password": "ENC(myrgA7xnANjqBI6e9sR7KgSlRNknizKEfXQ/Ejbzzk1s)" + }, + "database": { + "host": "192.168.50.79", + "port": 5437, + "user": "postgres", + "password": "ENC(ECwpzj4lHt7WdtbuQXPTveO/rhbvZnnolQh74DxlIw==)", + "default_db": "an_omon_full_04052026", + "databases": [ + { + "id": "an_omon_full_04052026", + "name": "BVĐK Ô Môn - Cần Thơ (33 GB - Chuẩn khoa phòng)" + }, + { + "id": "tuan_dbgovap_full", + "name": "BVĐK Gò Vấp - TP.HCM (65 GB - Dữ liệu lớn)" + }, + { + "id": "binh_bvq12_062026052026042026032026022026", + "name": "BV Quận 12 - TP.HCM (21 GB)" + }, + { + "id": "bvq12_697", + "name": "BV Quận 12 - Bản 697 (17 GB)" + }, + { + "id": "an_dalieu_full_05082026", + "name": "BV Da Liễu (31 GB)" + } + ] + }, + "updater": { + "enabled": true, + "gitea_url": "https://i.dh-his.com", + "owner": "ongtrieuhau", + "repo": "web-giao-ban", + "token": "ENC(abS+Aa/rBa8ydyLgFvGmDrJ5Vc9KOVTFu52PX4rCcA+88edzq5J6yWu05DPTTHWZEw2wKz+8RmXbt+jTTexJi+zATOg=)", + "current_version": "v1.0.0" + } +} \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..0e5d5fe --- /dev/null +++ b/config/config.go @@ -0,0 +1,264 @@ +package config + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" +) + +var secretKey = sha256.Sum256([]byte("dh-his-giaoban-secret-key-salt-2026-v1")) + +type DatabaseInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type ServerConfig struct { + Port int `json:"port"` + AdminPath string `json:"admin_path"` + AdminPassword string `json:"admin_password"` // stored as ENC(...) +} + +type DBConfig struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` // stored as ENC(...) + DefaultDB string `json:"default_db"` + Databases []DatabaseInfo `json:"databases"` +} + +type UpdaterConfig struct { + Enabled bool `json:"enabled"` + GiteaURL string `json:"gitea_url"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Token string `json:"token"` // stored as ENC(...) + CurrentVersion string `json:"current_version"` +} + +type AppConfig struct { + Server ServerConfig `json:"server"` + Database DBConfig `json:"database"` + Updater UpdaterConfig `json:"updater"` + + // runtime plaintext fields (not exported to JSON if needed) + DecryptedDBPassword string `json:"-"` + DecryptedUpdaterToken string `json:"-"` + DecryptedAdminPassword string `json:"-"` +} + +var ( + currentConfig *AppConfig + configMutex sync.RWMutex + configPath = "config.json" +) + +func Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + block, err := aes.NewCipher(secretKey[:]) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return "ENC(" + base64.StdEncoding.EncodeToString(ciphertext) + ")", nil +} + +func Decrypt(encText string) (string, error) { + if encText == "" { + return "", nil + } + if !strings.HasPrefix(encText, "ENC(") || !strings.HasSuffix(encText, ")") { + // Already plaintext + return encText, nil + } + + rawB64 := encText[4 : len(encText)-1] + data, err := base64.StdEncoding.DecodeString(rawB64) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(secretKey[:]) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext quá ngắn") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} + +func DefaultConfig() *AppConfig { + cfg := &AppConfig{ + Server: ServerConfig{ + Port: 3000, + AdminPath: "/admin", + AdminPassword: "admin", + }, + Database: DBConfig{ + Host: "192.168.50.79", + Port: 5437, + User: "postgres", + Password: "123", + DefaultDB: "an_omon_full_04052026", + Databases: []DatabaseInfo{ + {ID: "an_omon_full_04052026", Name: "BVĐK Ô Môn - Cần Thơ (33 GB - Chuẩn khoa phòng)"}, + {ID: "tuan_dbgovap_full", Name: "BVĐK Gò Vấp - TP.HCM (65 GB - Dữ liệu lớn)"}, + {ID: "binh_bvq12_062026052026042026032026022026", Name: "BV Quận 12 - TP.HCM (21 GB)"}, + {ID: "bvq12_697", Name: "BV Quận 12 - Bản 697 (17 GB)"}, + {ID: "an_dalieu_full_05082026", Name: "BV Da Liễu (31 GB)"}, + }, + }, + Updater: UpdaterConfig{ + Enabled: true, + GiteaURL: "https://i.dh-his.com", + Owner: "hdhiswork", + Repo: "DUAN", + Token: "", + CurrentVersion: "v1.0.0", + }, + } + return cfg +} + +func LoadConfig(path string) (*AppConfig, error) { + configMutex.Lock() + defer configMutex.Unlock() + + if path != "" { + configPath = path + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + cfg := DefaultConfig() + cfg.DecryptedAdminPassword = cfg.Server.AdminPassword + cfg.DecryptedDBPassword = cfg.Database.Password + cfg.DecryptedUpdaterToken = cfg.Updater.Token + + // Encrypt for disk save + encAdmin, _ := Encrypt(cfg.Server.AdminPassword) + encDB, _ := Encrypt(cfg.Database.Password) + encTok, _ := Encrypt(cfg.Updater.Token) + + cfg.Server.AdminPassword = encAdmin + cfg.Database.Password = encDB + cfg.Updater.Token = encTok + + data, _ := json.MarshalIndent(cfg, "", " ") + _ = os.MkdirAll(filepath.Dir(configPath), 0755) + _ = os.WriteFile(configPath, data, 0644) + + currentConfig = cfg + return cfg, nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + cfg := &AppConfig{} + if err := json.Unmarshal(data, cfg); err != nil { + return nil, err + } + + // Decrypt sensitive fields for runtime use + decAdmin, _ := Decrypt(cfg.Server.AdminPassword) + decDB, _ := Decrypt(cfg.Database.Password) + decTok, _ := Decrypt(cfg.Updater.Token) + + cfg.DecryptedAdminPassword = decAdmin + cfg.DecryptedDBPassword = decDB + cfg.DecryptedUpdaterToken = decTok + + currentConfig = cfg + return cfg, nil +} + +func SaveConfig(cfg *AppConfig) error { + configMutex.Lock() + defer configMutex.Unlock() + + clone := *cfg + + // Ensure passwords/tokens are stored encrypted + if !strings.HasPrefix(clone.Server.AdminPassword, "ENC(") { + clone.DecryptedAdminPassword = clone.Server.AdminPassword + enc, _ := Encrypt(clone.Server.AdminPassword) + clone.Server.AdminPassword = enc + } + if !strings.HasPrefix(clone.Database.Password, "ENC(") { + clone.DecryptedDBPassword = clone.Database.Password + enc, _ := Encrypt(clone.Database.Password) + clone.Database.Password = enc + } + if !strings.HasPrefix(clone.Updater.Token, "ENC(") { + clone.DecryptedUpdaterToken = clone.Updater.Token + enc, _ := Encrypt(clone.Updater.Token) + clone.Updater.Token = enc + } + + data, err := json.MarshalIndent(&clone, "", " ") + if err != nil { + return err + } + + tmpPath := configPath + ".tmp" + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + return err + } + if err := os.Rename(tmpPath, configPath); err != nil { + return err + } + + currentConfig = &clone + return nil +} + +func Get() *AppConfig { + configMutex.RLock() + defer configMutex.RUnlock() + if currentConfig == nil { + configMutex.RUnlock() + cfg, _ := LoadConfig("config.json") + return cfg + } + return currentConfig +} diff --git a/data/issues.json b/data/issues.json new file mode 100644 index 0000000..c2204bc --- /dev/null +++ b/data/issues.json @@ -0,0 +1,28 @@ +[ + { + "id": "ISS-20261025-001", + "title": "Kiểm tra đối chiếu số liệu tháng 03/2026", + "category": "SAI_LECH_SO_LIEU", + "module": "NGOAI_TRU", + "priority": "CAO", + "database": "an_omon_full_04052026", + "databaseName": "BVĐK Ô Môn - Cần Thơ", + "period": "Từ 2026-03-01 đến 2026-03-31", + "reporter": "BS. Trưởng Khoa KHTH", + "department": "Phòng Kế Hoạch Tổng Hợp", + "description": "Số liệu khám chữa bệnh BHYT tại Khoa Khám Bệnh đối chiếu rất tốt, gửi file test tổng hợp.", + "status": "DA_XU_LY", + "handlerNotes": "Đã kiểm tra đối chiếu khớp 100% với HIS Ô Môn.", + "resolvedBy": "Dev HIS", + "resolvedAt": "2026-10-25T10:26:47.059Z", + "createdAt": "2026-10-25T10:26:38.259Z", + "updatedAt": "2026-10-25T10:26:47.059Z", + "attachment": { + "filename": "sample_log_1792923998248-734023.txt", + "originalname": "sample_log.txt", + "size": 32, + "mimetype": "text/plain", + "url": "/uploads/issues/sample_log_1792923998248-734023.txt" + } + } +] \ No newline at end of file diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..5fdf78c --- /dev/null +++ b/db/db.go @@ -0,0 +1,156 @@ +package db + +import ( + "database/sql" + "fmt" + "regexp" + "strings" + "sync" + "time" + + _ "github.com/lib/pq" + "web-giao-ban-go/config" + "web-giao-ban-go/logger" +) + +var ( + poolMu sync.RWMutex + pools = make(map[string]*sql.DB) +) + +func GetConnectionString(cfg *config.AppConfig, dbName string) string { + if dbName == "" { + dbName = cfg.Database.DefaultDB + } + pass := cfg.DecryptedDBPassword + if pass == "" { + pass, _ = config.Decrypt(cfg.Database.Password) + } + + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable connect_timeout=10 statement_timeout=15000", + cfg.Database.Host, + cfg.Database.Port, + cfg.Database.User, + pass, + dbName, + ) +} + +func GetDB(dbName string) (*sql.DB, error) { + cfg := config.Get() + if dbName == "" { + dbName = cfg.Database.DefaultDB + } + + poolMu.RLock() + if conn, ok := pools[dbName]; ok { + poolMu.RUnlock() + return conn, nil + } + poolMu.RUnlock() + + poolMu.Lock() + defer poolMu.Unlock() + + // Double check + if conn, ok := pools[dbName]; ok { + return conn, nil + } + + connStr := GetConnectionString(cfg, dbName) + conn, err := sql.Open("postgres", connStr) + if err != nil { + logger.Error("Lỗi kết nối PostgreSQL [%s]: %v", dbName, err) + return nil, err + } + + conn.SetMaxOpenConns(15) + conn.SetMaxIdleConns(5) + conn.SetConnMaxLifetime(15 * time.Minute) + + if err := conn.Ping(); err != nil { + logger.Error("Ping thất bại tới CSDL [%s]: %v", dbName, err) + return nil, err + } + + pools[dbName] = conn + logger.Info("Khởi tạo connection pool tới CSDL: %s", dbName) + return conn, nil +} + +func CloseAll() { + poolMu.Lock() + defer poolMu.Unlock() + for name, conn := range pools { + _ = conn.Close() + delete(pools, name) + } +} + +func TestConnection(host string, port int, user, pass, dbName string) error { + connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable connect_timeout=5", + host, port, user, pass, dbName) + testConn, err := sql.Open("postgres", connStr) + if err != nil { + return err + } + defer testConn.Close() + return testConn.Ping() +} + +func EnsureTable(dbName string) { + conn, err := GetDB(dbName) + if err != nil { + return + } + + safeName := regexp.MustCompile(`[^a-zA-Z0-9]`).ReplaceAllString(dbName, "_") + sqlCreate := fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS current.giaoban_ghichu ( + id SERIAL PRIMARY KEY, + maba VARCHAR(50) NOT NULL, + mabn VARCHAR(20) NOT NULL, + loai_danhsach VARCHAR(20) NOT NULL, + ghichu TEXT, + ngay_capnhat TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unq_giaoban_item_%s UNIQUE(maba, loai_danhsach) + ); + `, safeName) + + _, _ = conn.Exec(sqlCreate) +} + +func CleanRTF(s string) string { + if s == "" { + return "" + } + if !strings.HasPrefix(s, "{\\rtf") && !strings.Contains(s, "\\rtf") { + return s + } + + cleaned := s + // Remove {\*...} blocks + re1 := regexp.MustCompile(`\{\\\*[^}]*\}`) + cleaned = re1.ReplaceAllString(cleaned, "") + + // Remove general {...} groups with tags + re2 := regexp.MustCompile(`\{[^{}]*\}`) + cleaned = re2.ReplaceAllString(cleaned, "") + + // Remove RTF control words (\b, \par, \fs20...) + re3 := regexp.MustCompile(`\\[a-zA-Z]+(-?\d+)? ?`) + cleaned = re3.ReplaceAllString(cleaned, " ") + + // Remove stray brackets and backslashes + re4 := regexp.MustCompile(`[\\{}]`) + cleaned = re4.ReplaceAllString(cleaned, "") + + // Collapse multiple spaces + re5 := regexp.MustCompile(`\s+`) + cleaned = strings.TrimSpace(re5.ReplaceAllString(cleaned, " ")) + + if cleaned == "" { + return "Có kết quả (Định dạng RTF)" + } + return cleaned +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4d07833 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module web-giao-ban-go + +go 1.22.2 + +require ( + github.com/getlantern/systray v1.2.2 + github.com/lib/pq v1.12.3 + github.com/minio/selfupdate v0.6.0 +) + +require ( + aead.dev/minisign v0.2.0 // indirect + github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect + github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect + github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect + github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect + github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect + github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect + github.com/go-stack/stack v1.8.0 // indirect + github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect + golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect + golang.org/x/sys v0.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f509dfd --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= +aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= +github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= +github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= +github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= diff --git a/handlers/admin.go b/handlers/admin.go new file mode 100644 index 0000000..b6d9726 --- /dev/null +++ b/handlers/admin.go @@ -0,0 +1,218 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "web-giao-ban-go/config" + "web-giao-ban-go/db" + "web-giao-ban-go/logger" + "web-giao-ban-go/updater" +) + +// GET /api/admin/config +func HandleGetAdminConfig(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + cfg := config.Get() + + // Mask sensitive fields + respCfg := *cfg + if respCfg.Server.AdminPassword != "" { + respCfg.Server.AdminPassword = "••••••••" + } + if respCfg.Database.Password != "" { + respCfg.Database.Password = "••••••••" + } + if respCfg.Updater.Token != "" { + respCfg.Updater.Token = "••••••••" + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": respCfg, + }) +} + +// POST /api/admin/config +func HandleSaveAdminConfig(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + var input config.AppConfig + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "JSON không hợp lệ: " + err.Error()}) + return + } + + current := config.Get() + + // Handle Admin Password + if input.Server.AdminPassword == "" || input.Server.AdminPassword == "••••••••" { + input.Server.AdminPassword = current.Server.AdminPassword + input.DecryptedAdminPassword = current.DecryptedAdminPassword + } else { + input.DecryptedAdminPassword = input.Server.AdminPassword + enc, _ := config.Encrypt(input.Server.AdminPassword) + input.Server.AdminPassword = enc + } + + // Handle DB Password + if input.Database.Password == "" || input.Database.Password == "••••••••" { + input.Database.Password = current.Database.Password + input.DecryptedDBPassword = current.DecryptedDBPassword + } else { + input.DecryptedDBPassword = input.Database.Password + enc, _ := config.Encrypt(input.Database.Password) + input.Database.Password = enc + } + + // Handle Updater Token + if input.Updater.Token == "" || input.Updater.Token == "••••••••" { + input.Updater.Token = current.Updater.Token + input.DecryptedUpdaterToken = current.DecryptedUpdaterToken + } else { + input.DecryptedUpdaterToken = input.Updater.Token + enc, _ := config.Encrypt(input.Updater.Token) + input.Updater.Token = enc + } + + if err := config.SaveConfig(&input); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Lưu file thất bại: " + err.Error()}) + return + } + + // Reset DB pool to apply new connection parameters + db.CloseAll() + logger.Info("Đã lưu cấu hình mới và làm mới kết nối CSDL") + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Đã lưu cấu hình và mã hóa mật khẩu thành công!", + }) +} + +// POST /api/admin/test-db +func HandleTestDBConnection(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + var body struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + Database string `json:"database"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Dữ liệu không hợp lệ"}) + return + } + + cfg := config.Get() + pass := body.Password + if pass == "" || pass == "••••••••" { + pass = cfg.DecryptedDBPassword + } + + err := db.TestConnection(body.Host, body.Port, body.User, pass, body.Database) + if err != nil { + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "Kết nối thất bại: " + err.Error(), + }) + return + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Kết nối PostgreSQL thành công!", + }) +} + +// GET /api/admin/logs +func HandleGetLogs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + limitStr := r.URL.Query().Get("limit") + level := r.URL.Query().Get("level") + + limit := 100 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + + entries := logger.Get().GetEntries(limit, strings.ToUpper(level)) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": entries, + }) +} + +// POST /api/admin/logs/clear +func HandleClearLogs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + logger.Get().Clear() + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Đã làm trống bộ nhớ log!", + }) +} + +// GET /api/update/check +func HandleCheckUpdate(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + res, err := updater.CheckUpdate() + if err != nil { + logger.Error("Kiểm tra cập nhật lỗi: %v", err) + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": err.Error(), + }) + return + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": res, + }) +} + +// POST /api/update/apply +func HandleApplyUpdate(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + var body struct { + AssetURL string `json:"asset_url"` + Version string `json:"version"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.AssetURL == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Thiếu thông tin URL bản cập nhật"}) + return + } + + err := updater.ApplyUpdate(body.AssetURL) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "Cập nhật thất bại: " + err.Error(), + }) + return + } + + // Update current version in config if provided + if body.Version != "" { + cfg := config.Get() + cfg.Updater.CurrentVersion = body.Version + _ = config.SaveConfig(cfg) + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Đã cập nhật phiên bản mới thành công! Vui lòng khởi động lại phần mềm để áp dụng.", + }) +} diff --git a/handlers/api.go b/handlers/api.go new file mode 100644 index 0000000..4108633 --- /dev/null +++ b/handlers/api.go @@ -0,0 +1,681 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "web-giao-ban-go/config" + "web-giao-ban-go/db" +) + +// Helper to generate partition filters: namkt IN (...) AND thangkt IN (...) +func GetThangNamFilter(fromStr, toStr, prefix string) string { + f, err1 := time.Parse("2006-01-02 15:04:05", fromStr) + t, err2 := time.Parse("2006-01-02 15:04:05", toStr) + if err1 != nil || err2 != nil { + // Try date only + f, err1 = time.Parse("2006-01-02", fromStr[:10]) + t, err2 = time.Parse("2006-01-02", toStr[:10]) + if err1 != nil || err2 != nil { + return "" + } + } + + thangsMap := make(map[string]bool) + namsMap := make(map[string]bool) + + curr := time.Date(f.Year(), f.Month(), 1, 0, 0, 0, 0, time.UTC) + end := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) + count := 0 + + for !curr.After(end) && count < 24 { + thangsMap[fmt.Sprintf("'%02d'", curr.Month())] = true + namsMap[fmt.Sprintf("'%d'", curr.Year())] = true + curr = curr.AddDate(0, 1, 0) + count++ + } + + var thangsArr, namsArr []string + for k := range thangsMap { + thangsArr = append(thangsArr, k) + } + for k := range namsMap { + namsArr = append(namsArr, k) + } + + if len(thangsArr) == 0 || len(namsArr) == 0 { + return "" + } + + p := "" + if prefix != "" { + p = prefix + "." + } + return fmt.Sprintf(" AND %snamkt IN (%s) AND %sthangkt IN (%s) ", + p, strings.Join(namsArr, ","), + p, strings.Join(thangsArr, ","), + ) +} + +// GET /api/databases +func HandleGetDatabases(w http.ResponseWriter, r *http.Request) { + cfg := config.Get() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": cfg.Database.Databases, + }) +} + +type OutpatientData struct { + Khoa []map[string]interface{} `json:"khoa"` + BANT map[string]interface{} `json:"bant"` + YDCT map[string]interface{} `json:"ydct"` + CLS []map[string]interface{} `json:"cls"` + Top10 []map[string]interface{} `json:"top10"` + TiemNgua int `json:"tiemngua"` +} + +func queryOutpatient(database, fromDate, toDate string) (*OutpatientData, error) { + conn, err := db.GetDB(database) + if err != nil { + return nil, err + } + + fnKb := GetThangNamFilter(fromDate, toDate, "kb") + fnLs := GetThangNamFilter(fromDate, toDate, "ls") + fnNone := GetThangNamFilter(fromDate, toDate, "") + + out := &OutpatientData{ + Khoa: make([]map[string]interface{}, 0), + BANT: map[string]interface{}{"moi": 0, "taikham": 0, "tong": 0}, + YDCT: map[string]interface{}{"dien_cham": 0, "vltl": 0, "sac_thuoc": 0, "cay_chi": 0}, + CLS: make([]map[string]interface{}, 0), + Top10: make([]map[string]interface{}, 0), + } + + var wg sync.WaitGroup + var mu sync.Mutex + + // 1. Khoa KCB + wg.Add(1) + go func() { + defer wg.Done() + sqlKhoa := fmt.Sprintf(` + SELECT + COALESCE(dv.madv, 'KHAC') AS madv, + COALESCE(dv.tendv, 'Khoa khác') AS tendv, + COUNT(kb.makb) AS tong_kham, + SUM(CASE WHEN dt.bhyt IN (1, 2) THEN 1 ELSE 0 END) AS bhyt, + SUM(CASE WHEN dt.bhyt IN (0, 4) THEN 1 ELSE 0 END) AS thuphi, + SUM(CASE WHEN kb.dakham = 6 THEN 1 ELSE 0 END) AS chuyenvien + FROM current.khambenh kb + LEFT JOIN current.psdangky dk ON kb.mabn = dk.mabn AND kb.makb = dk.makb AND COALESCE(dk.xoa, 0) = 0 + LEFT JOIN current.dmdoituong dt ON dk.madt = dt.madt + LEFT JOIN current.dmdonvi dv ON kb.madv = dv.madv + WHERE kb.ngaykcb BETWEEN $1 AND $2 %s + AND COALESCE(kb.xoa, 0) = 0 + AND COALESCE(kb.dakham, 0) != 0 + GROUP BY dv.madv, dv.tendv + ORDER BY tong_kham DESC; + `, fnKb) + + rows, err := conn.Query(sqlKhoa, fromDate, toDate) + if err == nil { + defer rows.Close() + var list []map[string]interface{} + for rows.Next() { + var madv, tendv string + var tong, bhyt, thuphi, cv int + _ = rows.Scan(&madv, &tendv, &tong, &bhyt, &thuphi, &cv) + list = append(list, map[string]interface{}{ + "madv": madv, "tendv": tendv, "tong_kham": tong, + "bhyt": bhyt, "thuphi": thuphi, "chuyenvien": cv, + }) + } + mu.Lock() + out.Khoa = list + mu.Unlock() + } + }() + + // 2. BANT + wg.Add(1) + go func() { + defer wg.Done() + sqlBant := ` + SELECT + COALESCE(SUM(CASE WHEN COALESCE(dk.taikham, 0) = 0 THEN 1 ELSE 0 END), 0) AS moi, + COALESCE(SUM(CASE WHEN COALESCE(dk.taikham, 0) = 1 THEN 1 ELSE 0 END), 0) AS taikham, + COUNT(*) AS tong + FROM current.bnnoitru nt + JOIN current.psdangky dk ON nt.mabn = dk.mabn AND nt.makb = dk.makb AND COALESCE(dk.xoa, 0) = 0 + WHERE nt.namvien = 0 AND COALESCE(nt.xoa, 0) = 0 + AND nt.ngayvv BETWEEN $1 AND $2; + ` + var moi, taikham, tong int + err := conn.QueryRow(sqlBant, fromDate, toDate).Scan(&moi, &taikham, &tong) + if err == nil { + mu.Lock() + out.BANT = map[string]interface{}{"moi": moi, "taikham": taikham, "tong": tong} + mu.Unlock() + } + }() + + // 3. YDCT + wg.Add(1) + go func() { + defer wg.Done() + sqlYdct := fmt.Sprintf(` + SELECT + COALESCE(SUM(CASE WHEN cls.tencls ILIKE '%%Điện châm%%' THEN ls.soluong ELSE 0 END), 0) AS dien_cham, + COALESCE(SUM(CASE WHEN cls.tencls ILIKE '%%Vật lý trị liệu%%' OR cls.tencls ILIKE '%%VLTL%%' OR cls.macls IN ('DI131','DI135') THEN ls.soluong ELSE 0 END), 0) AS vltl, + COALESCE(SUM(CASE WHEN cls.tencls ILIKE '%%Sắc thuốc%%' THEN ls.soluong ELSE 0 END), 0) AS sac_thuoc, + COALESCE(SUM(CASE WHEN cls.tencls ILIKE '%%Cấy chỉ%%' OR cls.tencls ILIKE '%%Chôn chỉ%%' THEN ls.soluong ELSE 0 END), 0) AS cay_chi + FROM current.chidinhcls ls + JOIN current.dmcls cls ON ls.macls = cls.macls + WHERE ls.ngaykcb BETWEEN $1 AND $2 %s + AND COALESCE(ls.xoa, 0) = 0; + `, fnLs) + var dc, vltl, st, cc float64 + err := conn.QueryRow(sqlYdct, fromDate, toDate).Scan(&dc, &vltl, &st, &cc) + if err == nil { + mu.Lock() + out.YDCT = map[string]interface{}{ + "dien_cham": int(dc), "vltl": int(vltl), "sac_thuoc": int(st), "cay_chi": int(cc), + } + mu.Unlock() + } + }() + + // 4. CLS + wg.Add(1) + go func() { + defer wg.Done() + sqlCls := fmt.Sprintf(` + SELECT + cls.kho, + cls.maloai, + COALESCE(loaicls.tenloai, cls.maloai) AS tenloai, + COUNT(DISTINCT ls.makb) AS so_bn, + COUNT(*) AS so_luot + FROM current.chidinhcls ls + JOIN current.dmcls cls ON ls.macls = cls.macls + LEFT JOIN current.dmloaicls loaicls ON cls.maloai = loaicls.maloai + WHERE ls.ngaykcb BETWEEN $1 AND $2 %s + AND COALESCE(ls.xoa, 0) = 0 + AND cls.kho IN ('XN', 'HA', 'CN') + GROUP BY cls.kho, cls.maloai, loaicls.tenloai + ORDER BY cls.kho, count(*) DESC; + `, fnLs) + rows, err := conn.Query(sqlCls, fromDate, toDate) + if err == nil { + defer rows.Close() + var list []map[string]interface{} + for rows.Next() { + var kho, maloai, tenloai string + var soBn, soLuot int + _ = rows.Scan(&kho, &maloai, &tenloai, &soBn, &soLuot) + list = append(list, map[string]interface{}{ + "kho": kho, "maloai": maloai, "tenloai": tenloai, "so_bn": soBn, "so_luot": soLuot, + }) + } + mu.Lock() + out.CLS = list + mu.Unlock() + } + }() + + // 5. Top 10 + wg.Add(1) + go func() { + defer wg.Done() + sqlTop10 := fmt.Sprintf(` + SELECT maicd, kqcdoan, COUNT(*) AS sl + FROM current.khambenh + WHERE ngaykcb BETWEEN $1 AND $2 %s + AND COALESCE(xoa, 0) = 0 + AND maicd IS NOT NULL AND maicd != '' + GROUP BY maicd, kqcdoan + ORDER BY sl DESC + LIMIT 10; + `, fnNone) + rows, err := conn.Query(sqlTop10, fromDate, toDate) + if err == nil { + defer rows.Close() + var list []map[string]interface{} + for rows.Next() { + var maicd, kqcdoan string + var sl int + _ = rows.Scan(&maicd, &kqcdoan, &sl) + list = append(list, map[string]interface{}{ + "maicd": maicd, "kqcdoan": kqcdoan, "sl": sl, + }) + } + mu.Lock() + out.Top10 = list + mu.Unlock() + } + }() + + // 6. Tiêm ngừa + wg.Add(1) + go func() { + defer wg.Done() + sqlTn := fmt.Sprintf(` + SELECT COUNT(kb.makb) AS sl + FROM current.khambenh kb + LEFT JOIN current.dmphong ph ON kb.maphong = ph.maphong + LEFT JOIN current.dmdonvi dv ON kb.madv = dv.madv + WHERE kb.ngaykcb BETWEEN $1 AND $2 %s + AND COALESCE(kb.xoa, 0) = 0 + AND COALESCE(kb.dakham, 0) != 0 + AND ( + COALESCE(ph.loai_bc_giaoban, 0) = 2 OR + ph.maphong = 'TN' OR + dv.madv = 'PTN' OR + ph.tenphong ILIKE '%%Tiêm ngừa%%' OR + ph.tenphong ILIKE '%%Tiêm chủng%%' + ); + `, fnKb) + var sl int + err := conn.QueryRow(sqlTn, fromDate, toDate).Scan(&sl) + if err == nil { + mu.Lock() + out.TiemNgua = sl + mu.Unlock() + } + }() + + wg.Wait() + return out, nil +} + +// GET /api/giao-ban-ngoai-tru +func HandleGiaoBanNgoaiTru(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + q := r.URL.Query() + database := q.Get("database") + from := q.Get("from") + to := q.Get("to") + compareFrom := q.Get("compareFrom") + compareTo := q.Get("compareTo") + + if from == "" { + from = "2026-03-01 00:00:00" + } + if to == "" { + to = "2026-03-31 23:59:59" + } + + curData, err := queryOutpatient(database, from, to) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()}) + return + } + + var compData *OutpatientData + if compareFrom != "" && compareTo != "" { + compData, _ = queryOutpatient(database, compareFrom, compareTo) + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "current": curData, + "compare": compData, + "periodCurrent": map[string]string{ + "from": from, "to": to, + }, + "periodCompare": func() interface{} { + if compareFrom != "" && compareTo != "" { + return map[string]string{"from": compareFrom, "to": compareTo} + } + return nil + }(), + }, + }) +} + +type InpatientData struct { + Khoa []map[string]interface{} `json:"khoa"` +} + +func queryInpatient(database, fromDate, toDate string) (*InpatientData, error) { + conn, err := db.GetDB(database) + if err != nil { + return nil, err + } + + sqlKhoa := ` + SELECT + dv.madv, + dv.tendv, + SUM(CASE WHEN nt.ngayvv < $1 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) AS benh_cu, + SUM(CASE WHEN nt.ngayvv BETWEEN $1 AND $2 THEN 1 ELSE 0 END) AS benh_moi, + SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND nt.ravien = 1 AND COALESCE(nt.makq, '01') NOT IN ('04','05','06') THEN 1 ELSE 0 END) AS on_xin_ve, + SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.makq IN ('04','06') OR (nt.maxt = '09' AND nt.makq = '04')) THEN 1 ELSE 0 END) AS nang_xin_ve, + SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 2 OR nt.maxt = '08') THEN 1 ELSE 0 END) AS tron_vien, + SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 4 OR nt.maxt = '03') THEN 1 ELSE 0 END) AS chuyen_vien, + SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 3 OR nt.maxt = '06') THEN 1 ELSE 0 END) AS tu_vong, + SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv > $2) THEN 1 ELSE 0 END) AS benh_hien_con, + SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) AND (COALESCE(nt.benhnang, 0) = 1 OR COALESCE(nt.mabatheodoi, '') != '' OR dv.madv IN ('17','29')) THEN 1 ELSE 0 END) AS benh_theo_doi, + SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) AND (nt.chamsoc ILIKE '%cấp I%' OR nt.chamsoc ILIKE '%cấp 1%' OR nt.chamsoc IN ('1', 'CS I', 'CSI')) THEN 1 ELSE 0 END) AS chamsoc_cap1 + FROM current.bnnoitru nt + JOIN current.dmdonvi dv ON nt.madv = dv.madv + WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0 + AND nt.ngayvv >= ($1::date - INTERVAL '180 days') + GROUP BY dv.madv, dv.tendv + HAVING SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) > 0 + ORDER BY benh_moi DESC; + ` + + rows, err := conn.Query(sqlKhoa, fromDate, toDate) + if err != nil { + return nil, err + } + defer rows.Close() + + list := make([]map[string]interface{}, 0) + for rows.Next() { + var madv, tendv string + var cu, moi, on, nang, tron, cv, tv, hiencon, td, cs1 int + _ = rows.Scan(&madv, &tendv, &cu, &moi, &on, &nang, &tron, &cv, &tv, &hiencon, &td, &cs1) + list = append(list, map[string]interface{}{ + "madv": madv, "tendv": tendv, + "benh_cu": cu, "benh_moi": moi, "on_xin_ve": on, "nang_xin_ve": nang, + "tron_vien": tron, "chuyen_vien": cv, "tu_vong": tv, "benh_hien_con": hiencon, + "benh_theo_doi": td, "chamsoc_cap1": cs1, + }) + } + + return &InpatientData{Khoa: list}, nil +} + +// GET /api/giao-ban-noi-tru +func HandleGiaoBanNoiTru(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + q := r.URL.Query() + database := q.Get("database") + from := q.Get("from") + to := q.Get("to") + compareFrom := q.Get("compareFrom") + compareTo := q.Get("compareTo") + + if from == "" { + from = "2026-03-01 00:00:00" + } + if to == "" { + to = "2026-03-31 23:59:59" + } + + db.EnsureTable(database) + + curData, err := queryInpatient(database, from, to) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()}) + return + } + + var compData *InpatientData + if compareFrom != "" && compareTo != "" { + compData, _ = queryInpatient(database, compareFrom, compareTo) + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "current": curData, + "compare": compData, + "periodCurrent": map[string]string{ + "from": from, "to": to, + }, + "periodCompare": func() interface{} { + if compareFrom != "" && compareTo != "" { + return map[string]string{"from": compareFrom, "to": compareTo} + } + return nil + }(), + }, + }) +} + +// GET /api/danh-sach-chuyen-vien +func HandleDanhSachChuyenVien(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + q := r.URL.Query() + database := q.Get("database") + from := q.Get("from") + to := q.Get("to") + madv := q.Get("madv") + + if from == "" { + from = "2026-03-01 00:00:00" + } + if to == "" { + to = "2026-03-31 23:59:59" + } + + conn, err := db.GetDB(database) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()}) + return + } + db.EnsureTable(database) + + sqlWhere := ` + WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0 + AND nt.ngayrv BETWEEN $1 AND $2 + AND (nt.ravien = 4 OR nt.maxt = '03') + ` + args := []interface{}{from, to} + if madv != "" && madv != "ALL" { + args = append(args, madv) + sqlWhere += fmt.Sprintf(" AND nt.madv = $%d ", len(args)) + } + + sqlStr := fmt.Sprintf(` + SELECT + nt.mabn, + nt.maba, + bn.holot || ' ' || bn.ten AS hoten, + COALESCE(bn.diachi, '') AS diachi, + to_char(nt.ngayvv, 'DD/MM/YYYY HH24:MI') AS ngayvv, + to_char(nt.ngayrv, 'DD/MM/YYYY HH24:MI') AS ngayrv, + COALESCE(dv.tendv, nt.madv) AS tendv, + COALESCE(nt.kqcdoan, '') AS chandoan_chinh, + COALESCE(nt.kqcdoanp, '') AS chandoan_phu, + COALESCE(cls_sub.ketqua_cls, 'Chưa có kết quả') AS ketqua_cls, + COALESCE(gb.ghichu, nt.ghichu, '') AS ghichu + FROM current.bnnoitru nt + JOIN current.dmbenhnhan bn ON nt.mabn = bn.mabn + LEFT JOIN current.dmdonvi dv ON nt.madv = dv.madv + LEFT JOIN current.giaoban_ghichu gb ON nt.maba = gb.maba AND gb.loai_danhsach = 'CHUYEN_VIEN' + LEFT JOIN LATERAL ( + SELECT string_agg(DISTINCT cls.tencls || COALESCE(': ' || NULLIF(ha.ketluan,''), ''), '; ') AS ketqua_cls + FROM current.chidinhcls ls + JOIN current.dmcls cls ON ls.macls = cls.macls + LEFT JOIN current.pskhamha ha ON ls.mabn = ha.mabn AND ls.macls = ha.macls AND ls.makb = ha.makb + WHERE ls.mabn = nt.mabn AND ls.makb = nt.makb AND COALESCE(ls.xoa, 0) = 0 + ) cls_sub ON true + %s + ORDER BY nt.ngayrv DESC + LIMIT 100; + `, sqlWhere) + + rows, err := conn.Query(sqlStr, args...) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()}) + return + } + defer rows.Close() + + list := make([]map[string]interface{}, 0) + for rows.Next() { + var mabn, maba, hoten, diachi, ngayvv, ngayrv, tendv, cdChinh, cdPhu, cls, ghichu string + _ = rows.Scan(&mabn, &maba, &hoten, &diachi, &ngayvv, &ngayrv, &tendv, &cdChinh, &cdPhu, &cls, &ghichu) + list = append(list, map[string]interface{}{ + "mabn": mabn, "maba": maba, "hoten": hoten, "diachi": diachi, + "ngayvv": ngayvv, "ngayrv": ngayrv, "tendv": tendv, + "chandoan_chinh": cdChinh, "chandoan_phu": cdPhu, + "ketqua_cls": db.CleanRTF(cls), "ghichu": ghichu, + }) + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": list, + }) +} + +// GET /api/danh-sach-theo-doi +func HandleDanhSachTheoDoi(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + q := r.URL.Query() + database := q.Get("database") + from := q.Get("from") + to := q.Get("to") + madv := q.Get("madv") + + if from == "" { + from = "2026-03-01 00:00:00" + } + if to == "" { + to = "2026-03-31 23:59:59" + } + + conn, err := db.GetDB(database) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()}) + return + } + db.EnsureTable(database) + + sqlWhere := ` + WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0 + AND (nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1)) + AND ( + COALESCE(nt.benhnang, 0) = 1 OR + COALESCE(nt.mabatheodoi, '') != '' OR + nt.chamsoc ILIKE '%cấp I%' OR + nt.chamsoc ILIKE '%cấp 1%' OR + nt.chamsoc IN ('1', 'CS I', 'CSI') OR + nt.madv IN ('17', '29') + ) + ` + args := []interface{}{from, to} + if madv != "" && madv != "ALL" { + args = append(args, madv) + sqlWhere += fmt.Sprintf(" AND nt.madv = $%d ", len(args)) + } + + sqlStr := fmt.Sprintf(` + SELECT + nt.mabn, + nt.maba, + bn.holot || ' ' || bn.ten AS hoten, + COALESCE(bn.diachi, '') AS diachi, + to_char(nt.ngayvv, 'DD/MM/YYYY HH24:MI') AS ngayvv, + to_char(nt.ngayrv, 'DD/MM/YYYY HH24:MI') AS ngayrv, + COALESCE(dv.tendv, nt.madv) AS tendv, + COALESCE(nt.kqcdoan, '') AS chandoan_chinh, + COALESCE(nt.kqcdoanp, '') AS chandoan_phu, + COALESCE(nt.chamsoc, 'Theo dõi đặc biệt') AS chamsoc, + COALESCE(cls_sub.ketqua_cls, 'Chưa có kết quả') AS ketqua_cls, + COALESCE(gb.ghichu, nt.ghichu, '') AS ghichu + FROM current.bnnoitru nt + JOIN current.dmbenhnhan bn ON nt.mabn = bn.mabn + LEFT JOIN current.dmdonvi dv ON nt.madv = dv.madv + LEFT JOIN current.giaoban_ghichu gb ON nt.maba = gb.maba AND gb.loai_danhsach = 'THEO_DOI' + LEFT JOIN LATERAL ( + SELECT string_agg(DISTINCT cls.tencls || COALESCE(': ' || NULLIF(ha.ketluan,''), ''), '; ') AS ketqua_cls + FROM current.chidinhcls ls + JOIN current.dmcls cls ON ls.macls = cls.macls + LEFT JOIN current.pskhamha ha ON ls.mabn = ha.mabn AND ls.macls = ha.macls AND ls.makb = ha.makb + WHERE ls.mabn = nt.mabn AND ls.makb = nt.makb AND COALESCE(ls.xoa, 0) = 0 + ) cls_sub ON true + %s + ORDER BY nt.ngayvv DESC + LIMIT 100; + `, sqlWhere) + + rows, err := conn.Query(sqlStr, args...) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()}) + return + } + defer rows.Close() + + list := make([]map[string]interface{}, 0) + for rows.Next() { + var mabn, maba, hoten, diachi, ngayvv, ngayrv, tendv, cdChinh, cdPhu, chamsoc, cls, ghichu string + _ = rows.Scan(&mabn, &maba, &hoten, &diachi, &ngayvv, &ngayrv, &tendv, &cdChinh, &cdPhu, &chamsoc, &cls, &ghichu) + list = append(list, map[string]interface{}{ + "mabn": mabn, "maba": maba, "hoten": hoten, "diachi": diachi, + "ngayvv": ngayvv, "ngayrv": ngayrv, "tendv": tendv, + "chandoan_chinh": cdChinh, "chandoan_phu": cdPhu, + "chamsoc": chamsoc, "ketqua_cls": db.CleanRTF(cls), "ghichu": ghichu, + }) + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": list, + }) +} + +// POST /api/cap-nhat-ghi-chu +func HandleCapNhatGhiChu(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + var body struct { + Maba string `json:"maba"` + Mabn string `json:"mabn"` + Loai string `json:"loai"` + Ghichu string `json:"ghichu"` + Database string `json:"database"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Maba == "" || body.Loai == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Thiếu thông tin maba hoặc loai danh sách"}) + return + } + + conn, err := db.GetDB(body.Database) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()}) + return + } + db.EnsureTable(body.Database) + + sqlUpsert := ` + INSERT INTO current.giaoban_ghichu (maba, mabn, loai_danhsach, ghichu, ngay_capnhat) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (maba, loai_danhsach) + DO UPDATE SET ghichu = EXCLUDED.ghichu, ngay_capnhat = NOW(); + ` + _, err = conn.Exec(sqlUpsert, body.Maba, body.Mabn, body.Loai, body.Ghichu) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()}) + return + } + + // Try sync to bnnoitru.ghichu + _, _ = conn.Exec("UPDATE current.bnnoitru SET ghichu = $1 WHERE maba = $2;", body.Ghichu, body.Maba) + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Đã lưu ghi chú thành công!", + }) +} diff --git a/handlers/issues.go b/handlers/issues.go new file mode 100644 index 0000000..a3235bd --- /dev/null +++ b/handlers/issues.go @@ -0,0 +1,287 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "web-giao-ban-go/logger" +) + +type AttachmentInfo struct { + Filename string `json:"filename"` + OriginalName string `json:"originalname"` + Size int64 `json:"size"` + MimeType string `json:"mimetype"` + URL string `json:"url"` +} + +type Issue struct { + ID string `json:"id"` + Title string `json:"title"` + Category string `json:"category"` + Module string `json:"module"` + Priority string `json:"priority"` + Database string `json:"database"` + DatabaseName string `json:"databaseName"` + Period string `json:"period"` + Reporter string `json:"reporter"` + Department string `json:"department"` + Description string `json:"description"` + Status string `json:"status"` // CHO_XU_LY, DANG_XU_LY, DA_XU_LY, DONG + HandlerNotes string `json:"handlerNotes"` + ResolvedBy string `json:"resolvedBy"` + ResolvedAt *string `json:"resolvedAt"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Attachment *AttachmentInfo `json:"attachment,omitempty"` +} + +var ( + issuesMu sync.RWMutex + dataFile = "data/issues.json" + uploadsDir = "uploads/issues" +) + +func EnsureDirs() { + _ = os.MkdirAll("data", 0755) + _ = os.MkdirAll(uploadsDir, 0755) + if _, err := os.Stat(dataFile); os.IsNotExist(err) { + _ = os.WriteFile(dataFile, []byte("[]"), 0644) + } +} + +func getAllIssues() []Issue { + EnsureDirs() + issuesMu.RLock() + defer issuesMu.RUnlock() + + raw, err := os.ReadFile(dataFile) + if err != nil { + return []Issue{} + } + var list []Issue + if err := json.Unmarshal(raw, &list); err != nil { + return []Issue{} + } + return list +} + +func saveIssues(list []Issue) error { + EnsureDirs() + issuesMu.Lock() + defer issuesMu.Unlock() + + data, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + tmp := dataFile + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, dataFile) +} + +func generateIssueID(all []Issue) string { + now := time.Now() + prefix := fmt.Sprintf("ISS-%s", now.Format("20060102")) + count := 1 + for _, item := range all { + if strings.HasPrefix(item.ID, prefix) { + count++ + } + } + return fmt.Sprintf("%s-%03d", prefix, count) +} + +// GET /api/issues +func HandleGetIssues(w http.ResponseWriter, r *http.Request) { + list := getAllIssues() + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": list, + }) +} + +// POST /api/issues +func HandleCreateIssue(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + // Limit 25MB + if err := r.ParseMultipartForm(25 << 20); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "File quá lớn (tối đa 25MB)"}) + return + } + + title := strings.TrimSpace(r.FormValue("title")) + if title == "" { + title = "Sự cố không có tiêu đề" + } + + all := getAllIssues() + id := generateIssueID(all) + nowStr := time.Now().Format(time.RFC3339) + + var attach *AttachmentInfo + file, header, err := r.FormFile("attachment") + if err == nil && file != nil { + defer file.Close() + ext := filepath.Ext(header.Filename) + cleanBase := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + return r + } + return '_' + }, strings.TrimSuffix(header.Filename, ext)) + + safeFilename := fmt.Sprintf("%s_%d%s", cleanBase, time.Now().UnixNano()/1e6, ext) + destPath := filepath.Join(uploadsDir, safeFilename) + + dst, err := os.Create(destPath) + if err == nil { + defer dst.Close() + _, _ = io.Copy(dst, file) + attach = &AttachmentInfo{ + Filename: safeFilename, + OriginalName: header.Filename, + Size: header.Size, + MimeType: header.Header.Get("Content-Type"), + URL: "/uploads/issues/" + safeFilename, + } + } + } + + newIssue := Issue{ + ID: id, + Title: title, + Category: r.FormValue("category"), + Module: r.FormValue("module"), + Priority: r.FormValue("priority"), + Database: r.FormValue("database"), + DatabaseName: r.FormValue("databaseName"), + Period: r.FormValue("period"), + Reporter: strings.TrimSpace(r.FormValue("reporter")), + Department: strings.TrimSpace(r.FormValue("department")), + Description: strings.TrimSpace(r.FormValue("description")), + Status: "CHO_XU_LY", + CreatedAt: nowStr, + UpdatedAt: nowStr, + Attachment: attach, + } + + all = append([]Issue{newIssue}, all...) + _ = saveIssues(all) + + logger.Info("Đã ghi nhận Issue mới: [%s] %s từ %s", id, title, newIssue.Reporter) + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": newIssue, + "message": "Đã ghi nhận báo lỗi thành công!", + }) +} + +// PUT /api/issues/{id} +func HandleUpdateIssue(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + pathParts := strings.Split(r.URL.Path, "/") + id := pathParts[len(pathParts)-1] + + var body struct { + Status string `json:"status"` + ResolvedBy string `json:"resolvedBy"` + HandlerNotes string `json:"handlerNotes"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Dữ liệu không hợp lệ"}) + return + } + + all := getAllIssues() + found := false + var updated Issue + nowStr := time.Now().Format(time.RFC3339) + + for i := range all { + if all[i].ID == id { + if body.Status != "" { + all[i].Status = body.Status + } + all[i].ResolvedBy = body.ResolvedBy + all[i].HandlerNotes = body.HandlerNotes + all[i].UpdatedAt = nowStr + + if (body.Status == "DA_XU_LY" || body.Status == "DONG") && all[i].ResolvedAt == nil { + all[i].ResolvedAt = &nowStr + } + updated = all[i] + found = true + break + } + } + + if !found { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Không tìm thấy issue"}) + return + } + + _ = saveIssues(all) + logger.Info("Cập nhật Issue [%s]: Trạng thái=%s, Kỹ thuật=%s", id, updated.Status, updated.ResolvedBy) + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": updated, + "message": "Cập nhật issue thành công!", + }) +} + +// DELETE /api/issues/{id} +func HandleDeleteIssue(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + pathParts := strings.Split(r.URL.Path, "/") + id := pathParts[len(pathParts)-1] + + all := getAllIssues() + filtered := make([]Issue, 0, len(all)) + var removed *Issue + + for _, item := range all { + if item.ID == id { + cpy := item + removed = &cpy + continue + } + filtered = append(filtered, item) + } + + if removed == nil { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Không tìm thấy issue"}) + return + } + + if removed.Attachment != nil && removed.Attachment.Filename != "" { + _ = os.Remove(filepath.Join(uploadsDir, removed.Attachment.Filename)) + } + + _ = saveIssues(filtered) + logger.Info("Đã xóa Issue [%s]", id) + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Đã xóa issue thành công!", + }) +} diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..72e5d64 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,121 @@ +package logger + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" +) + +type LogEntry struct { + Timestamp string `json:"timestamp"` + Level string `json:"level"` + Message string `json:"message"` +} + +type RingLogger struct { + mu sync.RWMutex + entries []LogEntry + maxSize int + file *os.File + multiOut io.Writer +} + +var globalLogger *RingLogger +var once sync.Once + +func Init(logFilePath string, maxSize int) *RingLogger { + once.Do(func() { + if maxSize <= 0 { + maxSize = 500 + } + dir := filepath.Dir(logFilePath) + if dir != "" && dir != "." { + _ = os.MkdirAll(dir, 0755) + } + + f, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + var out io.Writer = os.Stdout + if err == nil { + out = io.MultiWriter(os.Stdout, f) + } + + globalLogger = &RingLogger{ + entries: make([]LogEntry, 0, maxSize), + maxSize: maxSize, + file: f, + multiOut: out, + } + }) + return globalLogger +} + +func Get() *RingLogger { + if globalLogger == nil { + return Init("logs/app.log", 500) + } + return globalLogger +} + +func (l *RingLogger) Log(level, message string) { + ts := time.Now().Format("2006-01-02 15:04:05") + entry := LogEntry{ + Timestamp: ts, + Level: level, + Message: message, + } + + l.mu.Lock() + if len(l.entries) >= l.maxSize { + l.entries = l.entries[1:] + } + l.entries = append(l.entries, entry) + l.mu.Unlock() + + line := fmt.Sprintf("[%s] [%s] %s\n", ts, level, message) + if l.multiOut != nil { + _, _ = l.multiOut.Write([]byte(line)) + } +} + +func (l *RingLogger) GetEntries(limit int, levelFilter string) []LogEntry { + l.mu.RLock() + defer l.mu.RUnlock() + + result := make([]LogEntry, 0, len(l.entries)) + for i := len(l.entries) - 1; i >= 0; i-- { + e := l.entries[i] + if levelFilter != "" && levelFilter != "ALL" && e.Level != levelFilter { + continue + } + result = append(result, e) + if limit > 0 && len(result) >= limit { + break + } + } + return result +} + +func (l *RingLogger) Clear() { + l.mu.Lock() + defer l.mu.Unlock() + l.entries = make([]LogEntry, 0, l.maxSize) +} + +func Info(format string, a ...interface{}) { + Get().Log("INFO", fmt.Sprintf(format, a...)) +} + +func Warn(format string, a ...interface{}) { + Get().Log("WARN", fmt.Sprintf(format, a...)) +} + +func Error(format string, a ...interface{}) { + Get().Log("ERROR", fmt.Sprintf(format, a...)) +} + +func SQL(format string, a ...interface{}) { + Get().Log("SQL", fmt.Sprintf(format, a...)) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..92da74d --- /dev/null +++ b/main.go @@ -0,0 +1,228 @@ +package main + +import ( + "embed" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "strings" + "time" + + "web-giao-ban-go/config" + "web-giao-ban-go/handlers" + "web-giao-ban-go/logger" + "web-giao-ban-go/tray" +) + +//go:embed public/* +var embeddedFiles embed.FS + +func main() { + // Initialize logger + logger.Init("logs/app.log", 500) + logger.Info("=======================================================") + logger.Info("[DH-HIS] Phần Mềm Quản Lý Giao Ban Bệnh Viện (Golang)") + logger.Info("=======================================================") + + // Load or create config + cfg, err := config.LoadConfig("config.json") + if err != nil { + logger.Error("Không thể đọc file config.json: %v", err) + } else { + logger.Info("Đã nạp file cấu hình thành công (Port: %d, Admin: %s)", cfg.Server.Port, cfg.Server.AdminPath) + } + + handlers.EnsureDirs() + + // Run system tray (Windows GUI) or signal listener (Linux daemon) + tray.Run(func() { + startWebServer() + }) +} + +func startWebServer() { + cfg := config.Get() + port := cfg.Server.Port + if port <= 0 { + port = 3000 + } + + mux := http.NewServeMux() + + // Sub-filesystem for embedded public + pubFS, err := fs.Sub(embeddedFiles, "public") + if err != nil { + logger.Error("Lỗi đọc embedded filesystem: %v", err) + return + } + + // Static file handler + staticHandler := func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + p = strings.TrimPrefix(p, "/web-giao-ban-go") + p = strings.TrimPrefix(p, "/web-giao-ban") + if p == "" { + p = "/" + } + + cleanPath := strings.TrimPrefix(p, "/") + if cleanPath == "" { + cleanPath = "index.html" + } + + // Disable caching for HTML to avoid browser redirect caching + if strings.HasSuffix(cleanPath, ".html") || cleanPath == "index.html" { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + } + + // Try open in embedded fs + if f, err := pubFS.Open(cleanPath); err == nil { + defer f.Close() + if stat, err := f.Stat(); err == nil && !stat.IsDir() { + http.ServeContent(w, r, stat.Name(), stat.ModTime(), f.(io.ReadSeeker)) + return + } + } + + // Fallback to index.html for SPA routing + if f, err := pubFS.Open("index.html"); err == nil { + defer f.Close() + if stat, err := f.Stat(); err == nil { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + http.ServeContent(w, r, "index.html", stat.ModTime(), f.(io.ReadSeeker)) + return + } + } + + http.NotFound(w, r) + } + + // Serve Uploaded Files + _ = os.MkdirAll("uploads/issues", 0755) + uploadFileServer := http.StripPrefix("/uploads/", http.FileServer(http.Dir("uploads"))) + uploadFileServerPrefixed := http.StripPrefix("/web-giao-ban/uploads/", http.FileServer(http.Dir("uploads"))) + uploadFileServerPrefixedGo := http.StripPrefix("/web-giao-ban-go/uploads/", http.FileServer(http.Dir("uploads"))) + + mux.Handle("/uploads/", uploadFileServer) + mux.Handle("/web-giao-ban/uploads/", uploadFileServerPrefixed) + mux.Handle("/web-giao-ban-go/uploads/", uploadFileServerPrefixedGo) + + // Admin page + adminPath := cfg.Server.AdminPath + if adminPath == "" { + adminPath = "/admin" + } + adminHandler := func(w http.ResponseWriter, r *http.Request) { + if f, err := pubFS.Open("admin.html"); err == nil { + defer f.Close() + if stat, err := f.Stat(); err == nil { + http.ServeContent(w, r, "admin.html", stat.ModTime(), f.(io.ReadSeeker)) + return + } + } + http.NotFound(w, r) + } + mux.HandleFunc(adminPath, adminHandler) + mux.HandleFunc("/web-giao-ban"+adminPath, adminHandler) + mux.HandleFunc("/web-giao-ban-go"+adminPath, adminHandler) + + // API Routing helper to mount on /api/..., /web-giao-ban/api/..., and /web-giao-ban-go/api/... + mountAPI := func(pattern string, handler func(http.ResponseWriter, *http.Request)) { + mux.HandleFunc(pattern, handler) + mux.HandleFunc("/web-giao-ban"+pattern, handler) + mux.HandleFunc("/web-giao-ban-go"+pattern, handler) + } + + // Business APIs + mountAPI("/api/databases", handlers.HandleGetDatabases) + mountAPI("/api/giao-ban-ngoai-tru", handlers.HandleGiaoBanNgoaiTru) + mountAPI("/api/giao-ban-noi-tru", handlers.HandleGiaoBanNoiTru) + mountAPI("/api/danh-sach-chuyen-vien", handlers.HandleDanhSachChuyenVien) + mountAPI("/api/danh-sach-theo-doi", handlers.HandleDanhSachTheoDoi) + mountAPI("/api/cap-nhat-ghi-chu", handlers.HandleCapNhatGhiChu) + + // Issues Management APIs + mountAPI("/api/issues", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + handlers.HandleGetIssues(w, r) + case http.MethodPost: + handlers.HandleCreateIssue(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + mountAPI("/api/issues/", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPut: + handlers.HandleUpdateIssue(w, r) + case http.MethodDelete: + handlers.HandleDeleteIssue(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // Admin & Update APIs + mountAPI("/api/admin/config", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + handlers.HandleGetAdminConfig(w, r) + case http.MethodPost: + handlers.HandleSaveAdminConfig(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + mountAPI("/api/admin/test-db", handlers.HandleTestDBConnection) + mountAPI("/api/admin/logs", handlers.HandleGetLogs) + mountAPI("/api/admin/logs/clear", handlers.HandleClearLogs) + mountAPI("/api/update/check", handlers.HandleCheckUpdate) + mountAPI("/api/update/apply", handlers.HandleApplyUpdate) + + // Static website routes + mux.HandleFunc("/web-giao-ban-go", staticHandler) + mux.HandleFunc("/web-giao-ban-go/", staticHandler) + mux.HandleFunc("/web-giao-ban", staticHandler) + mux.HandleFunc("/web-giao-ban/", staticHandler) + mux.HandleFunc("/", staticHandler) + + // Enable CORS & Logging Middleware + corsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + t0 := time.Now() + mux.ServeHTTP(w, r) + + if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/web-giao-ban/api/") || strings.HasPrefix(r.URL.Path, "/web-giao-ban-go/api/") { + logger.Info("%s %s in %v", r.Method, r.URL.Path, time.Since(t0)) + } + }) + + addr := fmt.Sprintf("0.0.0.0:%d", port) + logger.Info("Web Server đang lắng nghe tại: http://localhost:%d", port) + logger.Info("Trang Giao Ban: http://localhost:%d/web-giao-ban-go (hoặc /web-giao-ban)", port) + logger.Info("Trang Quản Trị: http://localhost:%d/web-giao-ban-go%s", port, adminPath) + + server := &http.Server{ + Addr: addr, + Handler: corsHandler, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + } + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("Lỗi khởi động web server: %v", err) + } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..33be078 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,345 @@ + + + + + + DH-HIS | Quản Trị & Cấu Hình Hệ Thống Giao Ban + + + + + + + + +
+
+
+
+ +
+
+

+ DH-HIS Giao Ban | Quản Trị Hệ Thống + v1.0.0 +

+

Thiết lập kết nối CSDL, bảo mật mã hóa, kiểm tra cập nhật & theo dõi nhật ký

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

+ + Cấu Hình Web Server & CSDL PostgreSQL +

+ Mã hóa AES-256 +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Thông tin máy chủ PostgreSQL: +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+

+ + Danh Sách CSDL Bệnh Viện (Dropdown) +

+ +
+ +
+ + + + + + + + + + + +
Tên CSDL (DB Name)Tên Hiển Thị (Label)Xóa
+
+ +
+ Các CSDL này sẽ hiển thị trong dropdown chọn CSDL ngoài trang báo cáo giao ban để người dùng chuyển đổi linh hoạt. +
+
+ +
+ +
+ + + + + + + + + + + +
+ + +
+ + Thông báo +
+ + + + diff --git a/public/admin.js b/public/admin.js new file mode 100644 index 0000000..f8db2bf --- /dev/null +++ b/public/admin.js @@ -0,0 +1,446 @@ +// Admin & Configuration JavaScript +const API_BASE = window.location.pathname.startsWith('/web-giao-ban-go') ? '/web-giao-ban-go' : (window.location.pathname.startsWith('/web-giao-ban') ? '/web-giao-ban' : ''); +let autoRefreshTimer = null; +let currentUpdateAsset = null; + +document.addEventListener('DOMContentLoaded', () => { + initTabs(); + loadConfig(); + initLogViewer(); + initUpdateChecker(); + + // Handle URL hash tab + const hash = window.location.hash; + if (hash === '#tab-update') { + switchTab('tab-update'); + checkUpdate(); + } else if (hash === '#tab-logs') { + switchTab('tab-logs'); + } +}); + +function showToast(msg) { + const toast = document.getElementById('toast'); + const toastMsg = document.getElementById('toastMsg'); + if (!toast || !toastMsg) return; + toastMsg.innerText = msg; + toast.classList.remove('translate-y-20', 'opacity-0'); + toast.classList.add('translate-y-0', 'opacity-100'); + setTimeout(() => { + toast.classList.remove('translate-y-0', 'opacity-100'); + toast.classList.add('translate-y-20', 'opacity-0'); + }, 2500); +} + +function initTabs() { + document.querySelectorAll('.admin-tab').forEach(btn => { + btn.addEventListener('click', () => { + const target = btn.getAttribute('data-tab'); + switchTab(target); + }); + }); +} + +function switchTab(targetId) { + document.querySelectorAll('.admin-tab').forEach(b => { + const isTarget = b.getAttribute('data-tab') === targetId; + b.classList.toggle('active', isTarget); + b.classList.toggle('border-indigo-600', isTarget); + b.classList.toggle('text-indigo-600', isTarget); + b.classList.toggle('border-transparent', !isTarget); + b.classList.toggle('text-slate-500', !isTarget); + }); + + document.querySelectorAll('.admin-content').forEach(c => { + c.classList.toggle('hidden', c.id !== targetId); + }); + + if (targetId === 'tab-logs') { + fetchLogs(); + } +} + +// ------------------------------------------------------------------- +// CONFIGURATION +// ------------------------------------------------------------------- +async function loadConfig() { + try { + const res = await fetch(`${API_BASE}/api/admin/config`); + const json = await res.json(); + if (json.success && json.data) { + const cfg = json.data; + + // Server + document.getElementById('cfgPort').value = cfg.server.port || 3000; + document.getElementById('cfgAdminPath').value = cfg.server.admin_path || '/admin'; + + // Database + document.getElementById('cfgDbHost').value = cfg.database.host || '192.168.50.79'; + document.getElementById('cfgDbPort').value = cfg.database.port || 5437; + document.getElementById('cfgDbUser').value = cfg.database.user || 'postgres'; + document.getElementById('cfgDbPassword').value = ''; + document.getElementById('cfgDbDefault').value = cfg.database.default_db || ''; + + // Databases Table + renderDbList(cfg.database.databases || []); + + // Gitea Updater + document.getElementById('cfgGiteaUrl').value = cfg.updater.gitea_url || 'https://i.dh-his.com'; + document.getElementById('cfgGiteaOwner').value = cfg.updater.owner || 'hdhiswork'; + document.getElementById('cfgGiteaRepo').value = cfg.updater.repo || 'DUAN'; + document.getElementById('cfgGiteaToken').value = ''; + + const ver = cfg.updater.current_version || 'v1.0.0'; + document.getElementById('appVersionBadge').innerText = ver; + document.getElementById('currentVerDisplay').innerText = ver; + } + } catch (err) { + console.error('Lỗi nạp cấu hình:', err); + } +} + +function renderDbList(list) { + const tbody = document.getElementById('bodyDbList'); + tbody.innerHTML = ''; + list.forEach(db => { + addDbRow(db.id, db.name); + }); +} + +function addDbRow(id = '', name = '') { + const tbody = document.getElementById('bodyDbList'); + const tr = document.createElement('tr'); + tr.className = 'hover:bg-slate-50 transition-colors'; + tr.innerHTML = ` + + + + + + + + + + `; + tr.querySelector('.btn-del-row').addEventListener('click', () => tr.remove()); + tbody.appendChild(tr); +} + +document.getElementById('btnAddDbRow')?.addEventListener('click', () => addDbRow()); + +// Password Toggle +document.getElementById('btnToggleDbPass')?.addEventListener('click', () => { + const inp = document.getElementById('cfgDbPassword'); + const btn = document.getElementById('btnToggleDbPass'); + if (inp.type === 'password') { + inp.type = 'text'; + btn.innerText = 'Ẩn'; + } else { + inp.type = 'password'; + btn.innerText = 'Hiện'; + } +}); + +document.getElementById('btnToggleToken')?.addEventListener('click', () => { + const inp = document.getElementById('cfgGiteaToken'); + const btn = document.getElementById('btnToggleToken'); + if (inp.type === 'password') { + inp.type = 'text'; + btn.innerText = 'Ẩn'; + } else { + inp.type = 'password'; + btn.innerText = 'Hiện'; + } +}); + +// Test Connection +document.getElementById('btnTestConnection')?.addEventListener('click', async () => { + const resEl = document.getElementById('testConnResult'); + resEl.innerHTML = ' Đang thử kết nối...'; + + const body = { + host: document.getElementById('cfgDbHost').value, + port: parseInt(document.getElementById('cfgDbPort').value, 10), + user: document.getElementById('cfgDbUser').value, + password: document.getElementById('cfgDbPassword').value, + database: document.getElementById('cfgDbDefault').value, + }; + + try { + const res = await fetch(`${API_BASE}/api/admin/test-db`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const json = await res.json(); + if (json.success) { + resEl.innerHTML = ' Kết nối thành công!'; + } else { + resEl.innerHTML = ` ${json.message}`; + } + } catch (err) { + resEl.innerHTML = ` ${err.message}`; + } +}); + +// Save Config Form +document.getElementById('formConfig')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = document.getElementById('btnSaveConfig'); + btn.disabled = true; + btn.innerHTML = ' Đang lưu...'; + + const dbs = []; + document.querySelectorAll('#bodyDbList tr').forEach(tr => { + const id = tr.querySelector('.db-row-id').value.trim(); + const name = tr.querySelector('.db-row-name').value.trim(); + if (id) { + dbs.push({ id, name: name || id }); + } + }); + + const body = { + server: { + port: parseInt(document.getElementById('cfgPort').value, 10), + admin_path: document.getElementById('cfgAdminPath').value.trim(), + }, + database: { + host: document.getElementById('cfgDbHost').value.trim(), + port: parseInt(document.getElementById('cfgDbPort').value, 10), + user: document.getElementById('cfgDbUser').value.trim(), + password: document.getElementById('cfgDbPassword').value, + default_db: document.getElementById('cfgDbDefault').value.trim(), + databases: dbs, + }, + updater: { + enabled: true, + gitea_url: document.getElementById('cfgGiteaUrl').value.trim(), + owner: document.getElementById('cfgGiteaOwner').value.trim(), + repo: document.getElementById('cfgGiteaRepo').value.trim(), + token: document.getElementById('cfgGiteaToken').value, + current_version: document.getElementById('currentVerDisplay').innerText, + } + }; + + try { + const res = await fetch(`${API_BASE}/api/admin/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const json = await res.json(); + if (json.success) { + showToast('Đã lưu cấu hình & mã hóa thành công!'); + loadConfig(); + } else { + alert('Lỗi lưu cấu hình: ' + json.message); + } + } catch (err) { + alert('Lỗi kết nối server: ' + err.message); + } finally { + btn.disabled = false; + btn.innerHTML = ' Lưu Cấu Hình & Mã Hóa'; + } +}); + +// Save Gitea Config Form +document.getElementById('formGiteaConfig')?.addEventListener('submit', (e) => { + e.preventDefault(); + document.getElementById('formConfig').dispatchEvent(new Event('submit')); +}); + +// ------------------------------------------------------------------- +// LOG VIEWER +// ------------------------------------------------------------------- +function initLogViewer() { + document.getElementById('btnRefreshLogs')?.addEventListener('click', fetchLogs); + document.getElementById('selectLogLevel')?.addEventListener('change', fetchLogs); + document.getElementById('btnClearLogs')?.addEventListener('click', clearLogs); + + const chk = document.getElementById('chkAutoRefresh'); + chk?.addEventListener('change', () => { + if (chk.checked) { + startAutoRefresh(); + } else { + clearInterval(autoRefreshTimer); + } + }); + + startAutoRefresh(); +} + +function startAutoRefresh() { + clearInterval(autoRefreshTimer); + autoRefreshTimer = setInterval(() => { + const logsTab = document.getElementById('tab-logs'); + if (logsTab && !logsTab.classList.contains('hidden')) { + fetchLogs(); + } + }, 3000); +} + +async function fetchLogs() { + const level = document.getElementById('selectLogLevel')?.value || 'ALL'; + const box = document.getElementById('logTerminal'); + const countBadge = document.getElementById('logCountBadge'); + + try { + const res = await fetch(`${API_BASE}/api/admin/logs?limit=300&level=${level}`); + const json = await res.json(); + if (json.success && Array.isArray(json.data)) { + countBadge.innerText = `${json.data.length} dòng`; + + if (json.data.length === 0) { + box.innerHTML = '
Không có dòng log nào.
'; + return; + } + + box.innerHTML = ''; + json.data.forEach(entry => { + let colorClass = 'text-slate-300'; + let badgeColor = 'bg-slate-800 text-slate-300'; + + switch (entry.level) { + case 'INFO': + colorClass = 'text-emerald-400'; + badgeColor = 'bg-emerald-950 text-emerald-300 border border-emerald-800'; + break; + case 'WARN': + colorClass = 'text-amber-400'; + badgeColor = 'bg-amber-950 text-amber-300 border border-amber-800'; + break; + case 'ERROR': + colorClass = 'text-rose-400 font-bold'; + badgeColor = 'bg-rose-950 text-rose-300 border border-rose-800'; + break; + case 'SQL': + colorClass = 'text-cyan-400'; + badgeColor = 'bg-cyan-950 text-cyan-300 border border-cyan-800'; + break; + } + + const div = document.createElement('div'); + div.className = 'py-0.5 border-b border-slate-900/60 flex items-start gap-2'; + div.innerHTML = ` + ${entry.timestamp} + ${entry.level} + ${escapeHtml(entry.message)} + `; + box.appendChild(div); + }); + } + } catch (err) { + console.error('Lỗi tải log:', err); + } +} + +async function clearLogs() { + if (!confirm('Bạn có chắc muốn làm sạch toàn bộ bộ nhớ log không?')) return; + try { + await fetch(`${API_BASE}/api/admin/logs/clear`, { method: 'POST' }); + fetchLogs(); + showToast('Đã xóa bộ nhớ log!'); + } catch (err) { + alert('Lỗi xóa log: ' + err.message); + } +} + +// ------------------------------------------------------------------- +// UPDATE CHECKER (GITEA) +// ------------------------------------------------------------------- +function initUpdateChecker() { + document.getElementById('btnCheckUpdate')?.addEventListener('click', checkUpdate); + document.getElementById('btnApplyUpdate')?.addEventListener('click', applyUpdate); +} + +async function checkUpdate() { + const btn = document.getElementById('btnCheckUpdate'); + const latestDisplay = document.getElementById('latestVerDisplay'); + const notesEl = document.getElementById('updateReleaseNotes'); + const statusTitle = document.getElementById('updateStatusTitle'); + const applyArea = document.getElementById('applyUpdateArea'); + + btn.disabled = true; + btn.innerHTML = ' Đang kiểm tra...'; + + try { + const res = await fetch(`${API_BASE}/api/update/check`); + const json = await res.json(); + + if (json.success && json.data) { + const u = json.data; + latestDisplay.innerText = u.latest_version || u.current_version; + + if (u.has_update) { + statusTitle.innerHTML = ` Có bản cập nhật mới: ${u.release_title || u.latest_version}`; + notesEl.innerText = u.release_notes || 'Không có mô tả chi tiết.'; + + if (u.asset_url) { + currentUpdateAsset = u; + applyArea.classList.remove('hidden'); + document.getElementById('updateAssetInfo').innerText = `File: ${u.asset_name} (${(u.asset_size / (1024 * 1024)).toFixed(2)} MB)`; + } else { + applyArea.classList.add('hidden'); + notesEl.innerText += '\n\n(Lưu ý: Chưa tìm thấy file nhị phân tương thích trong bản release).'; + } + } else { + statusTitle.innerHTML = ` Bạn đang sử dụng phiên bản mới nhất (${u.current_version})`; + notesEl.innerText = u.release_notes || 'Hệ thống đã cập nhật đầy đủ các tính năng mới nhất.'; + applyArea.classList.add('hidden'); + } + } else { + latestDisplay.innerText = 'Lỗi kết nối'; + notesEl.innerText = json.message || 'Không thể kết nối đến máy chủ Gitea.'; + } + } catch (err) { + latestDisplay.innerText = 'Lỗi'; + notesEl.innerText = err.message; + } finally { + btn.disabled = false; + btn.innerHTML = ' Kiểm Tra Cập Nhật'; + } +} + +async function applyUpdate() { + if (!currentUpdateAsset || !currentUpdateAsset.asset_url) return; + if (!confirm(`Bạn có chắc muốn tự động cập nhật lên phiên bản ${currentUpdateAsset.latest_version}?`)) return; + + const btn = document.getElementById('btnApplyUpdate'); + btn.disabled = true; + btn.innerHTML = ' Đang tải và cài đặt...'; + + try { + const res = await fetch(`${API_BASE}/api/update/apply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + asset_url: currentUpdateAsset.asset_url, + version: currentUpdateAsset.latest_version, + }), + }); + const json = await res.json(); + if (json.success) { + alert(json.message); + location.reload(); + } else { + alert('Lỗi cập nhật: ' + json.message); + } + } catch (err) { + alert('Lỗi kết nối cập nhật: ' + err.message); + } finally { + btn.disabled = false; + btn.innerHTML = ' Tải & Cập Nhật Tự Động'; + } +} + +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..f7e8fca --- /dev/null +++ b/public/app.js @@ -0,0 +1,1544 @@ +// DH-HIS Quản Lý Giao Ban Web Client +const API_BASE = window.location.pathname.startsWith('/web-giao-ban-go') ? '/web-giao-ban-go' : (window.location.pathname.startsWith('/web-giao-ban') ? '/web-giao-ban' : ''); +let chartNgoaiTruInstance = null; +let chartNoiTruInstance = null; +let currentChartTypeNgoaiTru = 'khoa'; + +let rawNgoaiTruData = null; +let rawNoiTruData = null; +let rawBang3Data = []; +let rawBang4Data = []; +let rawIssuesData = []; +let currentIssueFile = null; +let editingIssueId = null; + +document.addEventListener('DOMContentLoaded', async () => { + initPresets(); + initTabs(); + initEvents(); + initIssueFeatures(); + await loadDatabases(); + loadData(); + loadIssues(false); +}); + +// Format Number with Vietnamese Thousand Separator +function fmt(num) { + if (num === null || num === undefined || isNaN(num)) return '0'; + return Number(num).toLocaleString('vi-VN'); +} + +// Show Toast +function showToast(msg) { + const toast = document.getElementById('toast'); + const toastMsg = document.getElementById('toastMsg'); + if (!toast || !toastMsg) return; + toastMsg.innerText = msg; + toast.classList.remove('translate-y-20', 'opacity-0'); + toast.classList.add('translate-y-0', 'opacity-100'); + setTimeout(() => { + toast.classList.remove('translate-y-0', 'opacity-100'); + toast.classList.add('translate-y-20', 'opacity-0'); + }, 2500); +} + +// Load dynamic databases list +async function loadDatabases() { + const selectDb = document.getElementById('selectDb'); + if (!selectDb) return; + try { + const res = await fetch(`${API_BASE}/api/databases`); + const json = await res.json(); + if (json.success && Array.isArray(json.data) && json.data.length > 0) { + const savedDb = localStorage.getItem('dh_giaoban_db') || selectDb.value || 'an_omon_full_04052026'; + selectDb.innerHTML = ''; + json.data.forEach(db => { + const opt = document.createElement('option'); + opt.value = db.id; + opt.textContent = db.name; + if (db.id === savedDb) opt.selected = true; + selectDb.appendChild(opt); + }); + updateDbBadge(); + } + } catch (err) { + console.warn('Không tải được danh sách CSDL từ server:', err); + } +} + +// Update Database Badge on Header +function updateDbBadge() { + const selectDb = document.getElementById('selectDb'); + const badge = document.getElementById('currentDbBadge'); + if (badge && selectDb && selectDb.selectedOptions[0]) { + const text = selectDb.selectedOptions[0].textContent.split('(')[0].trim(); + badge.innerHTML = ` ${text}`; + } +} + +// Preset Handler +function initPresets() { + const quickPreset = document.getElementById('quickPreset'); + const dateFrom = document.getElementById('dateFrom'); + const dateTo = document.getElementById('dateTo'); + + quickPreset.addEventListener('change', () => { + const val = quickPreset.value; + let f = '', t = ''; + + switch (val) { + case 'month-03-2026': + f = '2026-03-01'; + t = '2026-03-31'; + break; + case 'month-04-2026': + f = '2026-04-01'; + t = '2026-04-30'; + break; + case 'month-01-2026': + f = '2026-01-01'; + t = '2026-01-31'; + break; + case 'month-02-2026': + f = '2026-02-01'; + t = '2026-02-28'; + break; + case 'today': + f = '2026-03-31'; + t = '2026-03-31'; + break; + case 'yesterday': + f = '2026-03-30'; + t = '2026-03-30'; + break; + case 'this-month': + f = '2026-03-01'; + t = '2026-03-31'; + break; + case 'quarter-1': + f = '2026-01-01'; + t = '2026-03-31'; + break; + case 'year-2026': + f = '2026-01-01'; + t = '2026-12-31'; + break; + default: + return; + } + dateFrom.value = f; + dateTo.value = t; + loadData(); + }); +} + +// Tab Switching +function initTabs() { + const tabBtns = document.querySelectorAll('.tab-btn'); + const tabContents = document.querySelectorAll('.tab-content'); + + tabBtns.forEach(btn => { + btn.addEventListener('click', () => { + tabBtns.forEach(b => { + b.classList.remove('active', 'border-blue-600', 'text-blue-600'); + b.classList.add('border-transparent', 'text-slate-500'); + }); + btn.classList.add('active', 'border-blue-600', 'text-blue-600'); + btn.classList.remove('border-transparent', 'text-slate-500'); + + const target = btn.getAttribute('data-tab'); + tabContents.forEach(c => { + c.classList.toggle('hidden', c.id !== target); + }); + + // Resize charts or load issues + if (target === 'tab-ngoaitru' && chartNgoaiTruInstance) { + chartNgoaiTruInstance.resize(); + } else if (target === 'tab-noitru' && chartNoiTruInstance) { + chartNoiTruInstance.resize(); + } else if (target === 'tab-issues') { + loadIssues(true); + } + }); + }); +} + +// Events +function initEvents() { + const enableCompare = document.getElementById('enableCompare'); + const compareContainer = document.getElementById('compareContainer'); + const btnFilter = document.getElementById('btnFilter'); + const selectDb = document.getElementById('selectDb'); + const btnPrint = document.getElementById('btnPrint'); + const btnExport = document.getElementById('btnExport'); + + enableCompare.addEventListener('change', () => { + compareContainer.classList.toggle('hidden', !enableCompare.checked); + document.querySelectorAll('.th-compare').forEach(el => { + el.classList.toggle('hidden', !enableCompare.checked); + }); + loadData(); + }); + + btnFilter.addEventListener('click', () => loadData()); + selectDb.addEventListener('change', () => { + updateDbBadge(); + loadData(); + }); + + btnPrint.addEventListener('click', () => window.print()); + btnExport.addEventListener('click', () => exportExcel()); + + // Chart Type toggle for Outpatient + document.querySelectorAll('.chart-type-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.chart-type-btn').forEach(b => { + b.classList.remove('active', 'bg-blue-100', 'text-blue-700', 'font-semibold'); + b.classList.add('bg-slate-100', 'text-slate-600', 'font-medium'); + }); + btn.classList.add('active', 'bg-blue-100', 'text-blue-700', 'font-semibold'); + btn.classList.remove('bg-slate-100', 'text-slate-600', 'font-medium'); + currentChartTypeNgoaiTru = btn.getAttribute('data-chart'); + renderChartNgoaiTru(); + }); + }); + + // Filters for Table 3 & 4 + document.getElementById('filterKhoaCv').addEventListener('change', (e) => { + renderBang3(e.target.value); + }); + document.getElementById('filterKhoaTd').addEventListener('change', (e) => { + renderBang4(e.target.value); + }); +} + +// Get Request Params +function getParams() { + const database = document.getElementById('selectDb').value; + const from = document.getElementById('dateFrom').value + ' 00:00:00'; + const to = document.getElementById('dateTo').value + ' 23:59:59'; + const isCompare = document.getElementById('enableCompare').checked; + + let urlParams = `database=${database}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`; + if (isCompare) { + const compareFrom = document.getElementById('compareFrom').value + ' 00:00:00'; + const compareTo = document.getElementById('compareTo').value + ' 23:59:59'; + urlParams += `&compareFrom=${encodeURIComponent(compareFrom)}&compareTo=${encodeURIComponent(compareTo)}`; + } + return { database, from, to, isCompare, urlParams }; +} + +// Set Loading State +function setLoading(isLoading) { + const btnFilter = document.getElementById('btnFilter'); + const selectDb = document.getElementById('selectDb'); + const quickPreset = document.getElementById('quickPreset'); + + if (isLoading) { + if (btnFilter) { + btnFilter.disabled = true; + btnFilter.innerHTML = ' Đang truy vấn...'; + btnFilter.classList.add('opacity-75', 'cursor-not-allowed'); + } + if (selectDb) selectDb.disabled = true; + if (quickPreset) quickPreset.disabled = true; + + // Show skeletons/loading on tables + const bodyKhoa = document.getElementById('bodyKhoaKcb'); + if (bodyKhoa) bodyKhoa.innerHTML = ' Đang tải số liệu khám chữa bệnh...'; + + const bodyCls = document.getElementById('bodyCls'); + if (bodyCls) bodyCls.innerHTML = ' Đang tải Cận lâm sàng...'; + + const bodyTop10 = document.getElementById('bodyTop10'); + if (bodyTop10) bodyTop10.innerHTML = ' Đang tải Top 10 bệnh...'; + + const bodyNoiTru = document.getElementById('bodyNoiTru'); + if (bodyNoiTru) bodyNoiTru.innerHTML = ' Đang tải số liệu nội trú...'; + + const bodyBang3 = document.getElementById('bodyBang3'); + if (bodyBang3) bodyBang3.innerHTML = ' Đang tải danh sách chuyển viện...'; + + const bodyBang4 = document.getElementById('bodyBang4'); + if (bodyBang4) bodyBang4.innerHTML = ' Đang tải danh sách theo dõi & CS Cấp I...'; + } else { + if (btnFilter) { + btnFilter.disabled = false; + btnFilter.innerHTML = ' Xem Báo Cáo'; + btnFilter.classList.remove('opacity-75', 'cursor-not-allowed'); + } + if (selectDb) selectDb.disabled = false; + if (quickPreset) quickPreset.disabled = false; + } +} + +// Master Load Data +async function loadData() { + const { urlParams, database, from, to } = getParams(); + localStorage.setItem('dh_giaoban_db', database); + updateDbBadge(); + + setLoading(true); + + try { + const [resNt, resNoiTru, resB3, resB4] = await Promise.allSettled([ + fetch(`${API_BASE}/api/giao-ban-ngoai-tru?${urlParams}`).then(r => r.json()), + fetch(`${API_BASE}/api/giao-ban-noi-tru?${urlParams}`).then(r => r.json()), + fetch(`${API_BASE}/api/danh-sach-chuyen-vien?database=${database}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`).then(r => r.json()), + fetch(`${API_BASE}/api/danh-sach-theo-doi?database=${database}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`).then(r => r.json()), + ]); + + let hasSuccess = false; + let errors = []; + + // 1. Ngoại trú + if (resNt.status === 'fulfilled' && resNt.value && resNt.value.success) { + rawNgoaiTruData = resNt.value.data; + renderNgoaiTru(); + hasSuccess = true; + } else { + const err = resNt.status === 'rejected' ? resNt.reason?.message : resNt.value?.message; + errors.push(`Ngoại trú: ${err || 'Lỗi kết nối'}`); + const body = document.getElementById('bodyKhoaKcb'); + if (body) body.innerHTML = ` Không tải được số liệu ngoại trú: ${err || 'Lỗi'}`; + } + + // 2. Nội trú + if (resNoiTru.status === 'fulfilled' && resNoiTru.value && resNoiTru.value.success) { + rawNoiTruData = resNoiTru.value.data; + renderNoiTru(); + hasSuccess = true; + } else { + const err = resNoiTru.status === 'rejected' ? resNoiTru.reason?.message : resNoiTru.value?.message; + errors.push(`Nội trú: ${err || 'Lỗi kết nối'}`); + const body = document.getElementById('bodyNoiTru'); + if (body) body.innerHTML = ` Không tải được số liệu nội trú: ${err || 'Lỗi'}`; + } + + // 3. Bảng 3: Chuyển viện + if (resB3.status === 'fulfilled' && resB3.value && resB3.value.success) { + rawBang3Data = resB3.value.data; + populateKhoaDropdown('filterKhoaCv', rawBang3Data); + renderBang3('ALL'); + hasSuccess = true; + } else { + const err = resB3.status === 'rejected' ? resB3.reason?.message : resB3.value?.message; + errors.push(`Chuyển viện: ${err || 'Lỗi kết nối'}`); + const body = document.getElementById('bodyBang3'); + if (body) body.innerHTML = ` Không tải được danh sách chuyển viện`; + } + + // 4. Bảng 4: Theo dõi + if (resB4.status === 'fulfilled' && resB4.value && resB4.value.success) { + rawBang4Data = resB4.value.data; + populateKhoaDropdown('filterKhoaTd', rawBang4Data); + renderBang4('ALL'); + hasSuccess = true; + } else { + const err = resB4.status === 'rejected' ? resB4.reason?.message : resB4.value?.message; + errors.push(`Theo dõi: ${err || 'Lỗi kết nối'}`); + const body = document.getElementById('bodyBang4'); + if (body) body.innerHTML = ` Không tải được danh sách theo dõi`; + } + + if (hasSuccess) { + const selName = document.getElementById('selectDb').selectedOptions[0]?.textContent.split('(')[0].trim() || database; + showToast(`Đã nạp báo cáo CSDL: ${selName}`); + } + if (errors.length > 0) { + console.warn('Errors loading some report sections:', errors); + showToast(`Cảnh báo: ${errors.join('; ')}`); + } + } catch (err) { + console.error('Fatal load error:', err); + showToast('Lỗi nghiêm trọng khi tải dữ liệu: ' + err.message); + } finally { + setLoading(false); + } +} + +// Populate Khoa Dropdown +function populateKhoaDropdown(selectId, dataList) { + const sel = document.getElementById(selectId); + const currentVal = sel.value; + const depts = Array.from(new Set(dataList.map(item => item.tendv).filter(Boolean))); + sel.innerHTML = ''; + depts.forEach(d => { + sel.innerHTML += ``; + }); + if (depts.includes(currentVal)) { + sel.value = currentVal; + } +} + +// ======================================================== +// RENDER NGOẠI TRÚ +// ======================================================== +function renderNgoaiTru() { + if (!rawNgoaiTruData) return; + const cur = rawNgoaiTruData.current; + const comp = rawNgoaiTruData.compare; + const isCompare = !!comp; + + // KPIs + let totalKham = 0, totalBhyt = 0, totalThuPhi = 0, totalChuyenVien = 0; + cur.khoa.forEach(k => { + totalKham += parseInt(k.tong_kham || 0, 10); + totalBhyt += parseInt(k.bhyt || 0, 10); + totalThuPhi += parseInt(k.thuphi || 0, 10); + totalChuyenVien += parseInt(k.chuyenvien || 0, 10); + }); + + document.getElementById('kpiTongKham').innerText = fmt(totalKham); + document.getElementById('kpiBhyt').innerText = fmt(totalBhyt); + document.getElementById('kpiBhytPct').innerText = totalKham > 0 ? (totalBhyt / totalKham * 100).toFixed(1) + '% tổng lượt' : '0%'; + document.getElementById('kpiThuPhi').innerText = fmt(totalThuPhi); + document.getElementById('kpiThuPhiPct').innerText = totalKham > 0 ? (totalThuPhi / totalKham * 100).toFixed(1) + '% tổng lượt' : '0%'; + document.getElementById('kpiChuyenVien').innerText = fmt(totalChuyenVien); + document.getElementById('kpiTiemNgua').innerText = fmt(cur.tiemngua); + + // BANT + document.getElementById('kpiBantTong').innerText = fmt(cur.bant.tong); + document.getElementById('kpiBantMoi').innerText = fmt(cur.bant.moi); + document.getElementById('kpiBantTk').innerText = fmt(cur.bant.taikham); + + // YDCT + document.getElementById('ydctDienCham').innerText = fmt(cur.ydct.dien_cham); + document.getElementById('ydctVltl').innerText = fmt(cur.ydct.vltl); + document.getElementById('ydctSacThuoc').innerText = fmt(cur.ydct.sac_thuoc); + document.getElementById('ydctCayChi').innerText = fmt(cur.ydct.cay_chi); + + // Table Khoa + const tbody = document.getElementById('bodyKhoaKcb'); + tbody.innerHTML = ''; + + const compMap = {}; + if (comp && comp.khoa) { + comp.khoa.forEach(k => compMap[k.madv] = k); + } + + if (!cur.khoa || cur.khoa.length === 0) { + tbody.innerHTML = 'Không có dữ liệu khám chữa bệnh trong khoảng thời gian này'; + } else { + cur.khoa.forEach(row => { + const k1 = parseInt(row.tong_kham || 0, 10); + let compCols = ''; + if (isCompare) { + const k2 = parseInt(compMap[row.madv]?.tong_kham || 0, 10); + const diff = k1 - k2; + const pct = k2 > 0 ? ((diff / k2) * 100).toFixed(1) + '%' : (k1 > 0 ? '+100%' : '0%'); + const diffColor = diff > 0 ? 'text-emerald-600 font-bold' : (diff < 0 ? 'text-rose-600 font-bold' : 'text-slate-500'); + compCols = ` + ${fmt(k2)} + ${diff > 0 ? '+' : ''}${fmt(diff)} (${pct}) + `; + } + + tbody.innerHTML += ` + + ${row.madv} + ${row.tendv} + ${fmt(row.bhyt)} + ${fmt(row.thuphi)} + ${fmt(row.chuyenvien)} + ${fmt(row.tong_kham)} + ${compCols} + + `; + }); + } + + // Table Khoa Footer + const tfoot = document.getElementById('footKhoaKcb'); + let compFoot = ''; + if (isCompare && comp && comp.khoa) { + let compTotalKham = 0; + comp.khoa.forEach(k => compTotalKham += parseInt(k.tong_kham || 0, 10)); + const totalDiff = totalKham - compTotalKham; + const totalPct = compTotalKham > 0 ? ((totalDiff / compTotalKham) * 100).toFixed(1) + '%' : '0%'; + compFoot = ` + ${fmt(compTotalKham)} + ${totalDiff > 0 ? '+' : ''}${fmt(totalDiff)} (${totalPct}) + `; + } + tfoot.innerHTML = ` + + TỔNG CỘNG + ${fmt(totalBhyt)} + ${fmt(totalThuPhi)} + ${fmt(totalChuyenVien)} + ${fmt(totalKham)} + ${compFoot} + + `; + + // CLS Table + const bodyCls = document.getElementById('bodyCls'); + bodyCls.innerHTML = ''; + if (!cur.cls || cur.cls.length === 0) { + bodyCls.innerHTML = 'Không có chỉ định Cận lâm sàng nào trong kỳ'; + } else { + cur.cls.forEach(c => { + const nhomBadge = c.kho === 'XN' + ? 'Xét nghiệm' + : 'CĐHA / TDCN'; + + bodyCls.innerHTML += ` + + ${nhomBadge} + ${c.tenloai} (${c.maloai}) + ${fmt(c.so_bn)} + ${fmt(c.so_luot)} + + `; + }); + } + + // Top 10 Table + const bodyTop10 = document.getElementById('bodyTop10'); + bodyTop10.innerHTML = ''; + if (!cur.top10 || cur.top10.length === 0) { + bodyTop10.innerHTML = 'Không có dữ liệu chẩn đoán'; + } else { + cur.top10.forEach((item, idx) => { + bodyTop10.innerHTML += ` + + ${item.maicd} + ${item.kqcdoan} + ${fmt(item.sl)} + + `; + }); + } + + // Render Outpatient Chart + renderChartNgoaiTru(); +} + +// Render Chart Ngoại Trú (Chart.js) +function renderChartNgoaiTru() { + if (!rawNgoaiTruData) return; + const canvas = document.getElementById('chartNgoaiTru'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const cur = rawNgoaiTruData.current || {}; + const comp = rawNgoaiTruData.compare; + + if (chartNgoaiTruInstance) { + chartNgoaiTruInstance.destroy(); + chartNgoaiTruInstance = null; + } + + const khoaList = Array.isArray(cur.khoa) ? cur.khoa : []; + const clsList = Array.isArray(cur.cls) ? cur.cls : []; + + let labels = []; + let data1 = []; + let data2 = []; + + if (currentChartTypeNgoaiTru === 'khoa') { + labels = khoaList.slice(0, 7).map(k => k.tendv); + data1 = khoaList.slice(0, 7).map(k => parseInt(k.tong_kham || 0, 10)); + + if (comp && Array.isArray(comp.khoa)) { + const compMap = {}; + comp.khoa.forEach(k => compMap[k.madv] = k); + data2 = khoaList.slice(0, 7).map(k => parseInt(compMap[k.madv]?.tong_kham || 0, 10)); + } + } else { + labels = clsList.slice(0, 8).map(c => c.tenloai); + data1 = clsList.slice(0, 8).map(c => parseInt(c.so_luot || 0, 10)); + + if (comp && Array.isArray(comp.cls)) { + const compMap = {}; + comp.cls.forEach(c => compMap[c.maloai] = c); + data2 = clsList.slice(0, 8).map(c => parseInt(compMap[c.maloai]?.so_luot || 0, 10)); + } + } + + const datasets = [ + { + label: comp ? 'Kỳ hiện tại' : 'Lượt thực hiện', + data: data1, + backgroundColor: 'rgba(37, 99, 235, 0.85)', + borderRadius: 4, + } + ]; + + if (comp) { + datasets.push({ + label: 'Kỳ so sánh', + data: data2, + backgroundColor: 'rgba(234, 88, 12, 0.85)', + borderRadius: 4, + }); + } + + chartNgoaiTruInstance = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: datasets, + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + labels: { font: { size: 11, family: 'Inter, sans-serif' } }, + }, + }, + scales: { + y: { + beginAtZero: true, + ticks: { font: { size: 10 } }, + }, + x: { + ticks: { + font: { size: 10 }, + maxRotation: 30, + minRotation: 0, + }, + }, + }, + }, + }); +} + +// ======================================================== +// RENDER NỘI TRÚ +// ======================================================== +function renderNoiTru() { + if (!rawNoiTruData) return; + const cur = rawNoiTruData.current || {}; + const comp = rawNoiTruData.compare; + + let totalCu = 0, totalMoi = 0, totalOn = 0, totalNang = 0, totalTron = 0, totalCv = 0, totalTv = 0, totalHienCon = 0, totalTd = 0, totalCs1 = 0; + + const tbody = document.getElementById('bodyNoiTru'); + tbody.innerHTML = ''; + + const khoaList = Array.isArray(cur.khoa) ? cur.khoa : []; + + if (khoaList.length === 0) { + tbody.innerHTML = 'Không có dữ liệu nội trú trong khoảng thời gian này'; + } else { + khoaList.forEach(row => { + const cu = parseInt(row.benh_cu || 0, 10); + const moi = parseInt(row.benh_moi || 0, 10); + const on = parseInt(row.on_xin_ve || 0, 10); + const nang = parseInt(row.nang_xin_ve || 0, 10); + const tron = parseInt(row.tron_vien || 0, 10); + const cv = parseInt(row.chuyen_vien || 0, 10); + const tv = parseInt(row.tu_vong || 0, 10); + const hiencon = parseInt(row.benh_hien_con || 0, 10); + const td = parseInt(row.benh_theo_doi || 0, 10); + const cs1 = parseInt(row.chamsoc_cap1 || 0, 10); + + totalCu += cu; totalMoi += moi; totalOn += on; totalNang += nang; + totalTron += tron; totalCv += cv; totalTv += tv; + totalHienCon += hiencon; totalTd += td; totalCs1 += cs1; + + tbody.innerHTML += ` + + ${row.madv} + ${row.tendv} + ${fmt(cu)} + ${fmt(moi)} + ${fmt(on)} + ${fmt(nang)} + ${fmt(tron)} + ${fmt(cv)} + ${fmt(tv)} + ${fmt(hiencon)} + ${fmt(td)} + ${fmt(cs1)} + + `; + }); + } + + // KPI Nội Trú + const totalXuatVien = totalOn + totalNang + totalTron + totalCv + totalTv; + document.getElementById('kpiNtBenhCu').innerText = fmt(totalCu); + document.getElementById('kpiNtBenhMoi').innerText = fmt(totalMoi); + document.getElementById('kpiNtXuatVien').innerText = fmt(totalXuatVien); + document.getElementById('kpiNtOnPct').innerText = `Ổn về: ${fmt(totalOn)} ca`; + document.getElementById('kpiNtHienCon').innerText = fmt(totalHienCon); + document.getElementById('kpiNtTheoDoi').innerText = fmt(totalTd); + document.getElementById('kpiNtCsCap1').innerText = fmt(totalCs1); + + // Footer NoiTru Table + document.getElementById('footNoiTru').innerHTML = ` + + TỔNG CỘNG + ${fmt(totalCu)} + ${fmt(totalMoi)} + ${fmt(totalOn)} + ${fmt(totalNang)} + ${fmt(totalTron)} + ${fmt(totalCv)} + ${fmt(totalTv)} + ${fmt(totalHienCon)} + ${fmt(totalTd)} + ${fmt(totalCs1)} + + `; + + // Render Inpatient Chart + renderChartNoiTru(); +} + +// Render Chart Nội Trú +function renderChartNoiTru() { + if (!rawNoiTruData) return; + const canvas = document.getElementById('chartNoiTru'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const cur = rawNoiTruData.current || {}; + const comp = rawNoiTruData.compare; + + if (chartNoiTruInstance) { + chartNoiTruInstance.destroy(); + chartNoiTruInstance = null; + } + + const khoaList = Array.isArray(cur.khoa) ? cur.khoa : []; + const topKhoa = khoaList.slice(0, 8); + const labels = topKhoa.map(k => k.tendv); + const dataMoi = topKhoa.map(k => parseInt(k.benh_moi || 0, 10)); + const dataRaVien = topKhoa.map(k => ( + parseInt(k.on_xin_ve || 0, 10) + + parseInt(k.nang_xin_ve || 0, 10) + + parseInt(k.tron_vien || 0, 10) + + parseInt(k.chuyen_vien || 0, 10) + + parseInt(k.tu_vong || 0, 10) + )); + const dataHienCon = topKhoa.map(k => parseInt(k.benh_hien_con || 0, 10)); + + const datasets = [ + { + label: 'Bệnh mới vào viện', + data: dataMoi, + backgroundColor: 'rgba(37, 99, 235, 0.85)', + borderRadius: 4, + }, + { + label: 'Tổng xuất viện', + data: dataRaVien, + backgroundColor: 'rgba(16, 185, 129, 0.85)', + borderRadius: 4, + }, + { + label: 'Hiện còn đang điều trị', + data: dataHienCon, + backgroundColor: 'rgba(99, 102, 241, 0.85)', + borderRadius: 4, + } + ]; + + if (comp && Array.isArray(comp.khoa)) { + const compMap = {}; + comp.khoa.forEach(k => compMap[k.madv] = k); + const dataMoiComp = topKhoa.map(k => parseInt(compMap[k.madv]?.benh_moi || 0, 10)); + datasets.push({ + label: 'Bệnh mới (Kỳ so sánh)', + data: dataMoiComp, + backgroundColor: 'rgba(234, 88, 12, 0.85)', + borderRadius: 4, + }); + } + + chartNoiTruInstance = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: datasets, + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + labels: { font: { size: 11, family: 'Inter, sans-serif' } }, + }, + }, + scales: { + y: { beginAtZero: true }, + x: { ticks: { maxRotation: 25, minRotation: 0 } }, + }, + }, + }); +} + +// ======================================================== +// RENDER BẢNG 3: BỆNH NHÂN CHUYỂN VIỆN +// ======================================================== +function renderBang3(filterDept) { + const tbody = document.getElementById('bodyBang3'); + tbody.innerHTML = ''; + + const filtered = filterDept === 'ALL' + ? rawBang3Data + : rawBang3Data.filter(item => item.tendv === filterDept); + + document.getElementById('countBang3').innerText = `${filtered.length} ca`; + + if (filtered.length === 0) { + tbody.innerHTML = `Không có bệnh nhân chuyển viện trong thời gian này`; + return; + } + + filtered.forEach((row, index) => { + tbody.innerHTML += ` + + ${index + 1} + +
${row.mabn}
+
${row.maba}
+ + ${row.hoten} + ${row.diachi} + +
${row.ngayvv}
+
${row.ngayrv}
+ + ${row.tendv} + +
${row.chandoan_chinh}
+ ${row.chandoan_phu ? `
${row.chandoan_phu}
` : ''} + + ${row.ketqua_cls} + + + + + `; + }); + + attachNoteHandlers(); +} + +// ======================================================== +// RENDER BẢNG 4: BỆNH NHÂN THEO DÕI & CS CẤP I +// ======================================================== +function renderBang4(filterDept) { + const tbody = document.getElementById('bodyBang4'); + tbody.innerHTML = ''; + + const filtered = filterDept === 'ALL' + ? rawBang4Data + : rawBang4Data.filter(item => item.tendv === filterDept); + + document.getElementById('countBang4').innerText = `${filtered.length} ca`; + + if (filtered.length === 0) { + tbody.innerHTML = `Không có bệnh nhân cần theo dõi hoặc CS Cấp I`; + return; + } + + filtered.forEach((row, index) => { + tbody.innerHTML += ` + + ${index + 1} + +
${row.mabn}
+
${row.maba}
+ + ${row.hoten} + ${row.diachi} + ${row.ngayvv} + +
${row.tendv}
+ + ${row.chamsoc} + + + +
${row.chandoan_chinh}
+ ${row.chandoan_phu ? `
${row.chandoan_phu}
` : ''} + + ${row.ketqua_cls} + + + + + `; + }); + + attachNoteHandlers(); +} + +// INLINE NOTE AUTO-SAVE HANDLER +function attachNoteHandlers() { + document.querySelectorAll('.table-cell-edit').forEach(el => { + el.removeEventListener('change', handleNoteChange); + el.addEventListener('change', handleNoteChange); + }); +} + +async function handleNoteChange(e) { + const el = e.target; + const maba = el.getAttribute('data-maba'); + const mabn = el.getAttribute('data-mabn'); + const loai = el.getAttribute('data-loai'); + const ghichu = el.value.trim(); + const database = document.getElementById('selectDb').value; + + el.classList.add('saving'); + try { + const res = await fetch(`${API_BASE}/api/cap-nhat-ghi-chu`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ maba, mabn, loai, ghichu, database }), + }); + const result = await res.json(); + if (result.success) { + el.classList.remove('saving'); + el.classList.add('saved'); + showToast(`Đã lưu ghi chú BN ${mabn}`); + setTimeout(() => el.classList.remove('saved'), 2000); + } else { + throw new Error(result.message); + } + } catch (err) { + el.classList.remove('saving'); + alert('Lỗi lưu ghi chú: ' + err.message); + } +} + +// Export Excel Helper +function exportExcel() { + const activeTab = document.querySelector('.tab-btn.active').getAttribute('data-tab'); + const table = activeTab === 'tab-ngoaitru' + ? document.getElementById('tableKhoaKcb') + : document.getElementById('tableNoiTru'); + + let html = table.outerHTML; + let blob = new Blob(['\ufeff' + html], { type: 'application/vnd.ms-excel' }); + let url = URL.createObjectURL(blob); + let a = document.createElement('a'); + a.href = url; + a.download = `BaoCaoGiaoBan_${activeTab}_${Date.now()}.xls`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + showToast('Đã xuất tập tin Excel thành công!'); +} + +// ======================================================== +// CHỨC NĂNG QUẢN LÝ ISSUE, BÁO LỖI & LƯU FILE TỔNG HỢP +// ======================================================== + +function initIssueFeatures() { + // Modal Open / Close + const btnOpen1 = document.getElementById('btnOpenIssueModal'); + const btnOpen2 = document.getElementById('btnOpenIssueModal2'); + const btnClose = document.getElementById('btnCloseIssueModal'); + const btnCancel = document.getElementById('btnCancelIssue'); + + if (btnOpen1) btnOpen1.addEventListener('click', openCreateIssueModal); + if (btnOpen2) btnOpen2.addEventListener('click', openCreateIssueModal); + if (btnClose) btnClose.addEventListener('click', closeCreateIssueModal); + if (btnCancel) btnCancel.addEventListener('click', closeCreateIssueModal); + + // Form Submit + const formCreate = document.getElementById('formCreateIssue'); + if (formCreate) formCreate.addEventListener('submit', handleCreateIssue); + + // Update Modal + const btnCloseUp = document.getElementById('btnCloseUpdateModal'); + const btnCancelUp = document.getElementById('btnCancelUpdate'); + if (btnCloseUp) btnCloseUp.addEventListener('click', closeUpdateModal); + if (btnCancelUp) btnCancelUp.addEventListener('click', closeUpdateModal); + + const formUpdate = document.getElementById('formUpdateIssue'); + if (formUpdate) formUpdate.addEventListener('submit', handleUpdateIssue); + + // Lightbox + const btnCloseLb = document.getElementById('btnCloseLightbox'); + const modalLb = document.getElementById('modalImagePreview'); + if (btnCloseLb) btnCloseLb.addEventListener('click', closeLightbox); + if (modalLb) { + modalLb.addEventListener('click', (e) => { + if (e.target === modalLb) closeLightbox(); + }); + } + + // File Upload & Paste Zone + const pasteZone = document.getElementById('issuePasteZone'); + const fileInput = document.getElementById('issueFileInput'); + const btnRemove = document.getElementById('btnRemoveFile'); + + if (pasteZone) { + pasteZone.addEventListener('click', (e) => { + if (e.target.closest('#btnRemoveFile')) return; + fileInput.click(); + }); + } + + if (fileInput) { + fileInput.addEventListener('change', () => { + if (fileInput.files && fileInput.files[0]) { + setIssueFile(fileInput.files[0]); + } + }); + } + + if (btnRemove) { + btnRemove.addEventListener('click', (e) => { + e.stopPropagation(); + clearIssueFile(); + }); + } + + // Clipboard Paste (Ctrl+V) anywhere inside the modal or page + document.addEventListener('paste', (e) => { + const modal = document.getElementById('modalIssue'); + if (!modal || modal.classList.contains('hidden')) return; + + const items = e.clipboardData?.items; + if (!items) return; + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + const blob = items[i].getAsFile(); + if (blob) { + const ext = blob.type.split('/')[1] || 'png'; + const file = new File([blob], `screenshot_${Date.now()}.${ext}`, { type: blob.type }); + setIssueFile(file); + showToast('Đã dán ảnh chụp màn hình từ Clipboard!'); + break; + } + } + } + }); + + // Filters + const fStatus = document.getElementById('filterIssueStatus'); + const fCat = document.getElementById('filterIssueCategory'); + const fKeyword = document.getElementById('filterIssueKeyword'); + + if (fStatus) fStatus.addEventListener('change', renderIssuesTable); + if (fCat) fCat.addEventListener('change', renderIssuesTable); + if (fKeyword) fKeyword.addEventListener('input', renderIssuesTable); + + // Export Excel + const btnExportIssues = document.getElementById('btnExportIssuesExcel'); + if (btnExportIssues) btnExportIssues.addEventListener('click', exportIssuesExcel); +} + +function setIssueFile(file) { + currentIssueFile = file; + const prompt = document.getElementById('pasteZonePrompt'); + const preview = document.getElementById('issuePreviewArea'); + const nameEl = document.getElementById('issueFileName'); + const sizeEl = document.getElementById('issueFileSize'); + const imgEl = document.getElementById('issueImgPreview'); + const fileIcon = document.getElementById('issueFileIcon'); + + prompt.classList.add('hidden'); + preview.classList.remove('hidden'); + + nameEl.innerText = file.name; + sizeEl.innerText = `${(file.size / 1024).toFixed(1)} KB`; + + if (file.type.startsWith('image/')) { + imgEl.classList.remove('hidden'); + fileIcon.classList.add('hidden'); + const reader = new FileReader(); + reader.onload = (e) => { + imgEl.src = e.target.result; + }; + reader.readAsDataURL(file); + } else { + imgEl.classList.add('hidden'); + fileIcon.classList.remove('hidden'); + } +} + +function clearIssueFile() { + currentIssueFile = null; + const fileInput = document.getElementById('issueFileInput'); + if (fileInput) fileInput.value = ''; + + const prompt = document.getElementById('pasteZonePrompt'); + const preview = document.getElementById('issuePreviewArea'); + prompt.classList.remove('hidden'); + preview.classList.add('hidden'); +} + +function openCreateIssueModal() { + const selectDb = document.getElementById('selectDb'); + const dbName = selectDb.selectedOptions[0]?.textContent || selectDb.value; + const dateFrom = document.getElementById('dateFrom').value; + const dateTo = document.getElementById('dateTo').value; + + document.getElementById('issueDatabase').value = `${dbName} (${selectDb.value})`; + document.getElementById('issuePeriod').value = `Từ ${dateFrom} đến ${dateTo}`; + + document.getElementById('issueTitle').value = ''; + document.getElementById('issueDescription').value = ''; + clearIssueFile(); + + const modal = document.getElementById('modalIssue'); + modal.classList.remove('hidden'); +} + +function closeCreateIssueModal() { + const modal = document.getElementById('modalIssue'); + modal.classList.add('hidden'); +} + +async function handleCreateIssue(e) { + e.preventDefault(); + const btnSubmit = document.getElementById('btnSubmitIssue'); + btnSubmit.disabled = true; + btnSubmit.innerHTML = ' Đang lưu...'; + + try { + const selectDb = document.getElementById('selectDb'); + const dbValue = selectDb.value; + const dbName = selectDb.selectedOptions[0]?.textContent || dbValue; + + const formData = new FormData(); + formData.append('title', document.getElementById('issueTitle').value); + formData.append('category', document.getElementById('issueCategory').value); + formData.append('module', document.getElementById('issueModule').value); + formData.append('priority', document.getElementById('issuePriority').value); + formData.append('database', dbValue); + formData.append('databaseName', dbName); + formData.append('period', document.getElementById('issuePeriod').value); + formData.append('reporter', document.getElementById('issueReporter').value); + formData.append('department', document.getElementById('issueDepartment').value); + formData.append('description', document.getElementById('issueDescription').value); + + if (currentIssueFile) { + formData.append('attachment', currentIssueFile); + } + + const res = await fetch(`${API_BASE}/api/issues`, { + method: 'POST', + body: formData, + }); + const result = await res.json(); + if (result.success) { + showToast('Đã lưu báo lỗi & tệp thành công!'); + closeCreateIssueModal(); + await loadIssues(true); + } else { + throw new Error(result.message); + } + } catch (err) { + alert('Lỗi gửi báo lỗi: ' + err.message); + } finally { + btnSubmit.disabled = false; + btnSubmit.innerHTML = ' Gửi Báo Lỗi & Lưu File'; + } +} + +async function loadIssues(shouldShowToast = false) { + try { + const res = await fetch(`${API_BASE}/api/issues`); + const result = await res.json(); + if (result.success && Array.isArray(result.data)) { + rawIssuesData = result.data; + updateIssueKpis(); + renderIssuesTable(); + if (shouldShowToast) { + showToast(`Đã nạp ${rawIssuesData.length} issue`); + } + } + } catch (err) { + console.error('Error loading issues:', err); + } +} + +function updateIssueKpis() { + const total = rawIssuesData.length; + const pending = rawIssuesData.filter(i => i.status === 'CHO_XU_LY').length; + const processing = rawIssuesData.filter(i => i.status === 'DANG_XU_LY').length; + const resolved = rawIssuesData.filter(i => i.status === 'DA_XU_LY' || i.status === 'DONG').length; + + const totalEl = document.getElementById('issueKpiTotal'); + const pendingEl = document.getElementById('issueKpiPending'); + const procEl = document.getElementById('issueKpiProcessing'); + const resEl = document.getElementById('issueKpiResolved'); + + if (totalEl) totalEl.innerText = total; + if (pendingEl) pendingEl.innerText = pending; + if (procEl) procEl.innerText = processing; + if (resEl) resEl.innerText = resolved; + + const badge = document.getElementById('badgeIssueCount'); + if (badge) { + badge.innerText = pending; + badge.classList.toggle('bg-rose-100', pending > 0); + badge.classList.toggle('text-rose-700', pending > 0); + badge.classList.toggle('bg-slate-100', pending === 0); + badge.classList.toggle('text-slate-500', pending === 0); + } +} + +function renderIssuesTable() { + const tbody = document.getElementById('bodyIssuesList'); + if (!tbody) return; + + const fStatus = document.getElementById('filterIssueStatus')?.value || 'ALL'; + const fCat = document.getElementById('filterIssueCategory')?.value || 'ALL'; + const keyword = (document.getElementById('filterIssueKeyword')?.value || '').toLowerCase().trim(); + + const filtered = rawIssuesData.filter(item => { + if (fStatus !== 'ALL' && item.status !== fStatus) return false; + if (fCat !== 'ALL' && item.category !== fCat) return false; + if (keyword) { + const matchId = (item.id || '').toLowerCase().includes(keyword); + const matchTitle = (item.title || '').toLowerCase().includes(keyword); + const matchDesc = (item.description || '').toLowerCase().includes(keyword); + const matchReporter = (item.reporter || '').toLowerCase().includes(keyword); + const matchDept = (item.department || '').toLowerCase().includes(keyword); + if (!matchId && !matchTitle && !matchDesc && !matchReporter && !matchDept) return false; + } + return true; + }); + + if (filtered.length === 0) { + tbody.innerHTML = 'Không có issue nào phù hợp với bộ lọc.'; + return; + } + + tbody.innerHTML = ''; + filtered.forEach(row => { + // Badges + const catBadge = getCategoryBadge(row.category); + const priBadge = getPriorityBadge(row.priority); + const statusBadge = getStatusBadge(row.status); + const createdStr = formatDateTime(row.createdAt); + + // Attachment + let attachHtml = '-'; + if (row.attachment) { + const attachUrl = `${API_BASE}${row.attachment.url}`; + if (row.attachment.mimetype && row.attachment.mimetype.startsWith('image/')) { + attachHtml = ` + + `; + } else { + attachHtml = ` + + Tải file + + `; + } + } + + // Handler notes + let notesHtml = ''; + if (row.handlerNotes) { + notesHtml = ` +
+ Xử lý (${escapeHtml(row.resolvedBy || 'IT')}): ${escapeHtml(row.handlerNotes)} +
+ `; + } + + tbody.innerHTML += ` + + +
${row.id}
+
${createdStr}
+ + +
${escapeHtml(row.title)}
+
${escapeHtml(row.description)}
+ ${notesHtml} + + +
+
${catBadge}
+
${getModuleLabel(row.module)}
+
${priBadge}
+
+ + +
+ ${escapeHtml(row.databaseName || row.database)} +
+
${escapeHtml(row.period || '-')}
+ + +
${escapeHtml(row.reporter || 'Cán bộ')}
+
${escapeHtml(row.department || '-')}
+ + + ${attachHtml} + + + ${statusBadge} + + +
+ + +
+ + + `; + }); +} + +function getCategoryBadge(cat) { + switch (cat) { + case 'SAI_LECH_SO_LIEU': + return 'Sai lệch số liệu'; + case 'LOI_GIAO_DIEN': + return 'Lỗi giao diện'; + case 'LOI_KET_NOI': + return 'Lỗi kết nối'; + case 'DE_XUAT_CAI_TIEN': + return 'Đề xuất cải tiến'; + case 'YEU_CAU_MOI': + return 'Yêu cầu mới'; + default: + return 'Khác'; + } +} + +function getPriorityBadge(pri) { + switch (pri) { + case 'KHAN_CAP': + return 'Khẩn cấp'; + case 'CAO': + return 'Cao'; + case 'THAP': + return 'Thấp'; + default: + return 'Bình thường'; + } +} + +function getStatusBadge(st) { + switch (st) { + case 'CHO_XU_LY': + return ' Chờ xử lý'; + case 'DANG_XU_LY': + return ' Đang xử lý'; + case 'DA_XU_LY': + return ' Đã xử lý'; + case 'DONG': + return ' Đã đóng'; + default: + return st; + } +} + +function getModuleLabel(mod) { + switch (mod) { + case 'NGOAI_TRU': return '1. Ngoại trú'; + case 'NOI_TRU': return '2. Nội trú'; + case 'CHUYEN_VIEN': return 'Bảng 3. Chuyển viện'; + case 'THEO_DOI': return 'Bảng 4. BN Theo dõi'; + default: return 'Toàn hệ thống'; + } +} + +function formatDateTime(iso) { + if (!iso) return '-'; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + const day = String(d.getDate()).padStart(2, '0'); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const year = d.getFullYear(); + const hours = String(d.getHours()).padStart(2, '0'); + const minutes = String(d.getMinutes()).padStart(2, '0'); + return `${day}/${month}/${year} ${hours}:${minutes}`; +} + +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function openUpdateModal(issueId) { + const item = rawIssuesData.find(i => i.id === issueId); + if (!item) return; + + editingIssueId = issueId; + document.getElementById('updateModalIssueId').innerText = issueId; + document.getElementById('updateStatus').value = item.status || 'CHO_XU_LY'; + document.getElementById('updateResolvedBy').value = item.resolvedBy || ''; + document.getElementById('updateHandlerNotes').value = item.handlerNotes || ''; + + const modal = document.getElementById('modalUpdateIssue'); + modal.classList.remove('hidden'); +} + +function closeUpdateModal() { + const modal = document.getElementById('modalUpdateIssue'); + modal.classList.add('hidden'); + editingIssueId = null; +} + +async function handleUpdateIssue(e) { + e.preventDefault(); + if (!editingIssueId) return; + + const btn = document.getElementById('btnSaveUpdateIssue'); + btn.disabled = true; + btn.innerHTML = ' Đang lưu...'; + + try { + const status = document.getElementById('updateStatus').value; + const resolvedBy = document.getElementById('updateResolvedBy').value.trim(); + const handlerNotes = document.getElementById('updateHandlerNotes').value.trim(); + + const res = await fetch(`${API_BASE}/api/issues/${editingIssueId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status, resolvedBy, handlerNotes }), + }); + + const result = await res.json(); + if (result.success) { + showToast(`Đã cập nhật issue ${editingIssueId}`); + closeUpdateModal(); + await loadIssues(false); + } else { + throw new Error(result.message); + } + } catch (err) { + alert('Lỗi cập nhật issue: ' + err.message); + } finally { + btn.disabled = false; + btn.innerHTML = ' Lưu Cập Nhật'; + } +} + +async function handleDeleteIssue(issueId) { + if (!confirm(`Bạn có chắc muốn xóa Issue [${issueId}] này không?`)) return; + + try { + const res = await fetch(`${API_BASE}/api/issues/${issueId}`, { + method: 'DELETE', + }); + const result = await res.json(); + if (result.success) { + showToast(`Đã xóa issue ${issueId}`); + await loadIssues(false); + } else { + throw new Error(result.message); + } + } catch (err) { + alert('Lỗi xóa issue: ' + err.message); + } +} + +function openImageLightbox(url, caption) { + const modal = document.getElementById('modalImagePreview'); + const img = document.getElementById('lightboxImg'); + const cap = document.getElementById('lightboxCaption'); + img.src = url; + cap.innerText = caption || 'Hình ảnh sự cố'; + modal.classList.remove('hidden'); +} + +function closeLightbox() { + const modal = document.getElementById('modalImagePreview'); + modal.classList.add('hidden'); +} + +function exportIssuesExcel() { + if (rawIssuesData.length === 0) { + alert('Chưa có issue nào để xuất file!'); + return; + } + + let tableHtml = ` + + + + + + + + + + + + + + + + + + + + + + + `; + + rawIssuesData.forEach(row => { + tableHtml += ` + + + + + + + + + + + + + + + + + + + `; + }); + + tableHtml += '
Mã IssueNgày Báo CáoTiêu ĐềPhân LoạiPhân HệMức ĐộCSDLKỳ Thời GianNgười BáoKhoa / PhòngNội Dung Chi TiếtTrạng TháiKỹ Thuật Xử LýGhi Chú Giải PhápNgày Giải QuyếtTệp Đính Kèm
${escapeHtml(row.id)}${formatDateTime(row.createdAt)}${escapeHtml(row.title)}${escapeHtml(row.category)}${escapeHtml(row.module)}${escapeHtml(row.priority)}${escapeHtml(row.databaseName || row.database)}${escapeHtml(row.period)}${escapeHtml(row.reporter)}${escapeHtml(row.department)}${escapeHtml(row.description)}${escapeHtml(row.status)}${escapeHtml(row.resolvedBy || '')}${escapeHtml(row.handlerNotes || '')}${formatDateTime(row.resolvedAt)}${row.attachment ? escapeHtml(row.attachment.originalname) : ''}
'; + + const blob = new Blob(['\ufeff' + tableHtml], { type: 'application/vnd.ms-excel;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `DanhSach_Issues_GiaoBan_${Date.now()}.xls`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + showToast('Đã xuất file Excel tổng hợp issue thành công!'); +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9ab316461f79540beef7340d76e0f8f4fb6835df GIT binary patch literal 30568 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 Z(EkSu{dVLH8;#*(QI>-r|Nl$1|34$vK2!hz literal 0 HcmV?d00001 diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..55f7dda78170363294f89cc5fe35d7e9de747a9e GIT binary patch literal 2856 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 + + + + + DH-HIS | Quản Lý Giao Ban Hằng Ngày + + + + + + + + + + + + +
+
+
+
+ +
+
+

+ Phần Mềm Quản Lý Giao Ban Bệnh Viện + DH-HIS Web + + BVĐK Ô Môn + +

+

Báo cáo Giao ban Ngoại trú & Nội trú hằng ngày - Phân tích đối chiếu đa chu kỳ

+
+
+ +
+ +
+ + +
+ + + + + + +
+
+
+ + +
+
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + + + +
+
+ + + +
+
+ + +
+
+ + + +
+
+ + +
+ + + + +
+ + +
+
+
+ Tổng Khám + +
+
0
+
Lượt khám hoàn tất
+
+ +
+
+ Khám BHYT + +
+
0
+
0% tổng lượt
+
+ +
+
+ Khám Thu Phí + +
+
0
+
0% tổng lượt
+
+ +
+
+ Chuyển Viện + +
+
0
+
Tuyến trên & ngang
+
+ +
+
+ Tiêm Ngừa + +
+
0
+
Lượt tiêm chủng
+
+ +
+
+ Bệnh Án Ngoại Trú + +
+
0
+
+ Mới: 0 + Tái khám: 0 +
+
+
+ + +
+ + +
+
+

+ + 1.1. Số Liệu Khám Chữa Bệnh Theo Các Khoa +

+ Khám bệnh, Nội, Ngoại, CSSKSS, Nhi, YDCT +
+
+ + + + + + + + + + + + + + + + + + + +
MãKhoa Khám BệnhBHYTThu PhíChuyển ViệnTổng Khám
Đang tải dữ liệu...
+
+
+ + +
+ +
+
+

+ + 1.2. Chuyên Khoa Y Dược Cổ Truyền +

+ YDCT +
+
+
+ + Điện châm + + 0 +
+
+ + Vật lý trị liệu (VLTL) + + 0 +
+
+ + Sắc thuốc thang + + 0 +
+
+ + Cấy chỉ (Chôn chỉ) + + 0 +
+
+
+ + +
+
+

+ + Top 10 Chẩn Đoán Ngoại Trú +

+
+
+ + + + +
Đang tải dữ liệu...
+
+
+ +
+
+ + +
+ + +
+
+

+ + 1.3. Tổng Hợp Số Liệu Cận Lâm Sàng +

+ Xét nghiệm & CĐHA +
+
+ + + + + + + + + + + + +
Phân nhómLoại Kỹ ThuậtSố Bệnh NhânSố Lượt Thực Hiện
Đang tải dữ liệu...
+
+
+ + +
+
+

+ + Biểu Đồ Cột So Sánh Số Liệu +

+
+ + +
+
+
+ +
+
+ +
+ +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + Thông báo +
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..496fd59 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,54 @@ +/* Custom styles for DH-HIS Daily Briefing Web */ +@layer base { + body { + background-color: #f8fafc; + } +} + +.table-cell-edit { + background-color: #fffbeb; + transition: all 0.2s; + outline: none; + border-radius: 4px; + padding: 4px 6px; + width: 100%; + border: 1px dashed #f59e0b; +} + +.table-cell-edit:focus { + background-color: #ffffff; + border: 1px solid #2563eb; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); +} + +.table-cell-edit.saving { + background-color: #fef3c7; + color: #92400e; +} + +.table-cell-edit.saved { + background-color: #ecfdf5; + border-color: #10b981; +} + +/* Print styling */ +@media print { + header, section, .tab-btn, .chart-type-btn, #btnFilter, #btnPrint, #btnExport, #toast { + display: none !important; + } + body { + background: white !important; + font-size: 10pt; + } + .tab-content { + display: block !important; + } + table { + border: 1px solid #ccc !important; + page-break-inside: auto; + } + tr { + page-break-inside: avoid; + page-break-after: auto; + } +} diff --git a/restart.sh b/restart.sh new file mode 100755 index 0000000..d6faf1a --- /dev/null +++ b/restart.sh @@ -0,0 +1,5 @@ +#!/bin/bash +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$DIR/stop.sh" +sleep 1 +"$DIR/start.sh" diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..f528cef --- /dev/null +++ b/start.sh @@ -0,0 +1,28 @@ +#!/bin/bash +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$DIR" + +# Check if systemd service exists and active +if systemctl is-enabled --quiet web-giao-ban-go 2>/dev/null || systemctl is-active --quiet web-giao-ban-go 2>/dev/null; then + echo "Khởi động qua systemd service: web-giao-ban-go..." + sudo systemctl start web-giao-ban-go + sudo systemctl status web-giao-ban-go --no-pager + exit 0 +fi + +# Fallback to direct process +PID=$(pgrep -f "$DIR/web-giao-ban" || true) +if [ -n "$PID" ]; then + echo "Ứng dụng Go đang chạy với PID: $PID" + exit 0 +fi + +echo "Đang khởi động web-giao-ban nền..." +nohup ./web-giao-ban > /dev/null 2>&1 & +sleep 1 +PID=$(pgrep -f "$DIR/web-giao-ban" || true) +if [ -n "$PID" ]; then + echo "Khởi động thành công! PID: $PID" +else + echo "Khởi động thất bại, kiểm tra logs/app.log" +fi diff --git a/status.sh b/status.sh new file mode 100755 index 0000000..e0193eb --- /dev/null +++ b/status.sh @@ -0,0 +1,14 @@ +#!/bin/bash +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if systemctl list-unit-files | grep -q "web-giao-ban-go.service"; then + sudo systemctl status web-giao-ban-go --no-pager + exit 0 +fi + +PID=$(pgrep -f "$DIR/web-giao-ban" || true) +if [ -n "$PID" ]; then + echo "● web-giao-ban (Go) is RUNNING (PID: $PID)" +else + echo "○ web-giao-ban (Go) is STOPPED" +fi diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..6c76e93 --- /dev/null +++ b/stop.sh @@ -0,0 +1,18 @@ +#!/bin/bash +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if systemctl is-active --quiet web-giao-ban-go; then + echo "Dừng systemd service web-giao-ban-go..." + sudo systemctl stop web-giao-ban-go + exit 0 +fi + +PID=$(pgrep -f "$DIR/web-giao-ban" || true) +if [ -n "$PID" ]; then + echo "Đang dừng PID: $PID..." + kill $PID + sleep 1 + echo "Đã dừng." +else + echo "Ứng dụng không chạy." +fi diff --git a/tray/app.ico b/tray/app.ico new file mode 100644 index 0000000000000000000000000000000000000000..9ab316461f79540beef7340d76e0f8f4fb6835df GIT binary patch literal 30568 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 Z(EkSu{dVLH8;#*(QI>-r|Nl$1|34$vK2!hz literal 0 HcmV?d00001 diff --git a/tray/icon.go b/tray/icon.go new file mode 100644 index 0000000..ea7fe79 --- /dev/null +++ b/tray/icon.go @@ -0,0 +1,13 @@ +package tray + +import ( + _ "embed" +) + +//go:embed app.ico +var iconData []byte + +// DefaultIconBytes returns the embedded ICO byte slice +func DefaultIconBytes() []byte { + return iconData +} diff --git a/tray/icon.png b/tray/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..71d13456210f69ac9d4973e3c8feb4acf381017b GIT binary patch literal 1272 zcmVc9+X%d<1>%-nO9ANS6*(@x720atP}ckbP@*V_B+ zz0VxL$8ORhY18|>lb5}lv@jAR1$P;FPMf5N2){boy8OZW8lSER1R}RxlmcJ?v4rWN zY~LTT3wuRme}Ut8d)JmM?;c!|C;sf(20-M8lhL)S7SG=bz%>A1%$;2hrKr3+`^R6} z-$7@h?>tGZwbql3#N=|MF{ClxeVNdjnxyVb^v3L|0lN->5}AD9V%sI}Z~8l-0t8b7 zPMw-Gs}GaSBC&G^vX_z=>WLxo?M_hZN%;^6CNF^W1f)|qxB5k-J34UV_tWTD^D3Cl z7TJ`8o92zi0LB3MB{IfKw+NhY1mZZ5KoHKXuxCSpt_!H)*u=8N@qQBlGzOwnku&(t zsI^B#dZgO*mOupJIHMeKIDoPR_~hyHok;BX8mVX$Vp*8>$YRvLwgz@}wU?a{_Eso_ zOyVNqTQ?&Wjl!y|#O&vuM|k-PFN=05;xjk7DCME6G@07?}L&LiG`HBFMn z7_YZomn50oy^BWx#+o*b>El?_Cbl%p=82ipc(wIs_8mIN=IToRGjKw>{%g5n0siHt2H4VA%X91AEKClQ%XybCuBe}^!s1vb z&Q7!~T7Wt4zK0o)KLJ2+<@hngw`|6&Wy_FBT!2ZZKvzQ#42;Wf(rK8X!SVz^1OmXS zsKCN~2hj8JdL+Ksj(F225M{xsslmJr8&UJjvl#5|f?YiwP!_6|EG;?d03ftrA*R$f z7W@~{#rvt|L^C0V?Nqvm%ek$~7X0+EOxR_W1N4)R