commit 228d4d14df4dbfe30e4745d3d60568ec02e462f6 Author: ongtrieuhau Date: Mon Oct 26 00:10:07 2026 -0700 feat: DH-HIS Web Giao Ban Go v1.0.1 (Favicon, Exe icon, System Tray, Gitea auto-update) 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 0000000..9ab3164 Binary files /dev/null and b/app.ico differ 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 0000000..9ab3164 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..55f7dda Binary files /dev/null and b/public/icon.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..736a638 --- /dev/null +++ b/public/index.html @@ -0,0 +1,907 @@ + + + + + + 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 0000000..9ab3164 Binary files /dev/null and b/tray/app.ico differ 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 0000000..71d1345 Binary files /dev/null and b/tray/icon.png differ diff --git a/tray/tray_other.go b/tray/tray_other.go new file mode 100644 index 0000000..e9760cc --- /dev/null +++ b/tray/tray_other.go @@ -0,0 +1,23 @@ +//go:build !windows + +package tray + +import ( + "os" + "os/signal" + "syscall" + + "web-giao-ban-go/logger" +) + +func Run(onServerReady func()) { + if onServerReady != nil { + go onServerReady() + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + sig := <-sigChan + logger.Info("Nhận tín hiệu dừng hệ thống (%v), đang thoát...", sig) +} diff --git a/tray/tray_windows.go b/tray/tray_windows.go new file mode 100644 index 0000000..05accbf --- /dev/null +++ b/tray/tray_windows.go @@ -0,0 +1,71 @@ +//go:build windows + +package tray + +import ( + "fmt" + "os/exec" + + "github.com/getlantern/systray" + "web-giao-ban-go/config" + "web-giao-ban-go/logger" +) + +func openBrowser(url string) { + _ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() +} + +func Run(onServerReady func()) { + systray.Run(func() { + cfg := config.Get() + baseURL := fmt.Sprintf("http://localhost:%d", cfg.Server.Port) + adminPath := cfg.Server.AdminPath + if adminPath == "" { + adminPath = "/admin" + } + + systray.SetIcon(DefaultIconBytes()) + systray.SetTitle("DH-HIS Giao Ban") + systray.SetTooltip(fmt.Sprintf("DH-HIS Quản Lý Giao Ban (Port %d)", cfg.Server.Port)) + + mTitle := systray.AddMenuItem(fmt.Sprintf("🏥 DH-HIS Giao Ban (%s)", cfg.Updater.CurrentVersion), "Phiên bản phần mềm") + mTitle.Disable() + + systray.AddSeparator() + + mOpenWeb := systray.AddMenuItem("📊 Mở Báo Cáo Giao Ban", "Mở giao diện báo cáo giao ban bệnh viện") + mOpenAdmin := systray.AddMenuItem("⚙️ Quản Trị Hệ Thống", "Thiết lập kết nối CSDL và tham số") + mOpenLogs := systray.AddMenuItem("📜 Xem Nhật Ký / Log App", "Xem trực tiếp log hoạt động của ứng dụng") + mCheckUpdate := systray.AddMenuItem("🔄 Kiểm Tra Bản Cập Nhật", "Kiểm tra phiên bản mới từ Gitea") + + systray.AddSeparator() + mQuit := systray.AddMenuItem("❌ Thoát Ứng Dụng", "Dừng dịch vụ và thoát") + + // Start web server in background + if onServerReady != nil { + go onServerReady() + } + + // Handle tray menu events + go func() { + for { + select { + case <-mOpenWeb.ClickedCh: + openBrowser(baseURL + "/web-giao-ban-go") + case <-mOpenAdmin.ClickedCh: + openBrowser(baseURL + "/web-giao-ban-go" + adminPath) + case <-mOpenLogs.ClickedCh: + openBrowser(baseURL + "/web-giao-ban-go" + adminPath + "#tab-logs") + case <-mCheckUpdate.ClickedCh: + openBrowser(baseURL + "/web-giao-ban-go" + adminPath + "#tab-update") + case <-mQuit.ClickedCh: + logger.Info("Người dùng chọn Thoát từ khay hệ thống (System Tray)") + systray.Quit() + return + } + } + }() + }, func() { + logger.Info("System Tray đã đóng") + }) +} diff --git a/updater/updater.go b/updater/updater.go new file mode 100644 index 0000000..a13a408 --- /dev/null +++ b/updater/updater.go @@ -0,0 +1,190 @@ +package updater + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "runtime" + "strings" + "time" + + "github.com/minio/selfupdate" + "web-giao-ban-go/config" + "web-giao-ban-go/logger" +) + +type GiteaAsset struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + DownloadURL string `json:"browser_download_url"` +} + +type GiteaRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + PublishedAt time.Time `json:"published_at"` + Assets []GiteaAsset `json:"assets"` +} + +type CheckResult struct { + HasUpdate bool `json:"has_update"` + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + ReleaseTitle string `json:"release_title"` + ReleaseNotes string `json:"release_notes"` + AssetName string `json:"asset_name"` + AssetURL string `json:"asset_url"` + AssetSize int64 `json:"asset_size"` +} + +func CheckUpdate() (*CheckResult, error) { + cfg := config.Get() + if !cfg.Updater.Enabled { + return &CheckResult{HasUpdate: false, CurrentVersion: cfg.Updater.CurrentVersion}, nil + } + + baseURL := strings.TrimRight(cfg.Updater.GiteaURL, "/") + if baseURL == "" { + baseURL = "https://i.dh-his.com" + } + owner := cfg.Updater.Owner + if owner == "" { + owner = "hdhiswork" + } + repo := cfg.Updater.Repo + if repo == "" { + repo = "DUAN" + } + + apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/latest", baseURL, owner, repo) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, err + } + + token := cfg.DecryptedUpdaterToken + if token == "" { + token, _ = config.Decrypt(cfg.Updater.Token) + } + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return &CheckResult{ + HasUpdate: false, + CurrentVersion: cfg.Updater.CurrentVersion, + LatestVersion: cfg.Updater.CurrentVersion, + ReleaseNotes: "Chưa có bản phát hành nào trên Gitea", + }, nil + } + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Gitea API trả về mã lỗi %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var rel GiteaRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, err + } + + curVer := cfg.Updater.CurrentVersion + hasNew := isNewerVersion(rel.TagName, curVer) + + // Pick asset according to OS + var matchedAsset *GiteaAsset + for _, a := range rel.Assets { + lowerName := strings.ToLower(a.Name) + if runtime.GOOS == "windows" { + if strings.HasSuffix(lowerName, ".exe") { + matchedAsset = &a + break + } + } else { + if strings.Contains(lowerName, "linux") || (!strings.HasSuffix(lowerName, ".exe") && !strings.HasSuffix(lowerName, ".zip")) { + matchedAsset = &a + break + } + } + } + + res := &CheckResult{ + HasUpdate: hasNew, + CurrentVersion: curVer, + LatestVersion: rel.TagName, + ReleaseTitle: rel.Name, + ReleaseNotes: rel.Body, + } + + if matchedAsset != nil { + res.AssetName = matchedAsset.Name + res.AssetURL = matchedAsset.DownloadURL + res.AssetSize = matchedAsset.Size + } + + return res, nil +} + +func isNewerVersion(latest, current string) bool { + latest = strings.TrimPrefix(strings.TrimSpace(latest), "v") + current = strings.TrimPrefix(strings.TrimSpace(current), "v") + if latest == "" { + return false + } + if current == "" { + return true + } + return latest != current +} + +func ApplyUpdate(downloadURL string) error { + cfg := config.Get() + logger.Info("Bắt đầu tải bản cập nhật từ: %s", downloadURL) + + req, err := http.NewRequest("GET", downloadURL, nil) + if err != nil { + return err + } + + token := cfg.DecryptedUpdaterToken + if token == "" { + token, _ = config.Decrypt(cfg.Updater.Token) + } + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("tải bản cập nhật thất bại với HTTP status %d", resp.StatusCode) + } + + err = selfupdate.Apply(resp.Body, selfupdate.Options{}) + if err != nil { + logger.Error("Self-update thất bại: %v", err) + return err + } + + logger.Info("Đã cập nhật binary ứng dụng thành công! Vui lòng khởi động lại ứng dụng.") + return nil +}