3 Commits
32 changed files with 5923 additions and 2 deletions
+6
View File
@@ -0,0 +1,6 @@
web-giao-ban
web-giao-ban.exe
*.syso
*.rc
logs/
uploads/
+114 -2
View File
@@ -1,3 +1,115 @@
# web-giao-ban # DH-HIS Quản Lý Giao Ban Hằng Ngày (Golang Standalone)
Phần mềm Quản lý Giao ban hằng ngày (Golang Standalone Single Binary) Ứ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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Executable
+30
View File
@@ -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 "======================================================="
+44
View File
@@ -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.1"
}
}
+272
View File
@@ -0,0 +1,272 @@
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: "ongtrieuhau",
Repo: "web-giao-ban",
Token: "",
CurrentVersion: "v1.0.2",
},
}
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
// Auto-migrate legacy owner & repo
if cfg.Updater.Owner == "" || cfg.Updater.Owner == "hdhiswork" {
cfg.Updater.Owner = "ongtrieuhau"
}
if cfg.Updater.Repo == "" || cfg.Updater.Repo == "DUAN" {
cfg.Updater.Repo = "web-giao-ban"
}
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
}
+28
View File
@@ -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"
}
}
]
+156
View File
@@ -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
}
+23
View File
@@ -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
)
+55
View File
@@ -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=
+218
View File
@@ -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.",
})
}
+681
View File
@@ -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!",
})
}
+287
View File
@@ -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!",
})
}
+121
View File
@@ -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...))
}
+228
View File
@@ -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)
}
}
+345
View File
@@ -0,0 +1,345 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DH-HIS | Quản Trị &amp; Cấu Hình Hệ Thống Giao Ban</title>
<link rel="icon" type="image/x-icon" href="favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="styles.css">
</head>
<body class="bg-slate-100 text-slate-800 font-sans antialiased min-h-screen flex flex-col">
<!-- TOP HEADER -->
<header class="bg-gradient-to-r from-slate-900 via-indigo-950 to-slate-900 text-white shadow-lg sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-3.5 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="bg-indigo-600/30 p-2 rounded-xl border border-indigo-400/30 text-cyan-300">
<i class="fa-solid fa-sliders text-2xl"></i>
</div>
<div>
<h1 class="text-lg font-bold tracking-tight uppercase flex items-center gap-2">
DH-HIS Giao Ban | Quản Trị Hệ Thống
<span class="text-xs bg-indigo-500/30 text-indigo-200 border border-indigo-400/30 px-2 py-0.5 rounded-full font-mono" id="appVersionBadge">v1.0.2</span>
</h1>
<p class="text-xs text-slate-400">Thiết lập kết nối CSDL, bảo mật mã hóa, kiểm tra cập nhật &amp; theo dõi nhật ký</p>
</div>
</div>
<div class="flex items-center gap-2">
<a href="index.html?v=1.0.2" class="bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold px-3 py-2 rounded-lg shadow-sm flex items-center gap-1.5 transition-all">
<i class="fa-solid fa-hospital-user"></i>
<span>Mở Báo Cáo Giao Ban</span>
</a>
</div>
</div>
</header>
<!-- NAVIGATION TABS -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 mt-4 w-full">
<div class="flex border-b border-slate-200 gap-2 flex-wrap">
<button class="admin-tab active px-5 py-2.5 text-sm font-bold border-b-2 border-indigo-600 text-indigo-600 flex items-center gap-2 transition-all" data-tab="tab-config">
<i class="fa-solid fa-database"></i>
<span>1. Cấu Hình Kết Nối &amp; CSDL</span>
</button>
<button class="admin-tab px-5 py-2.5 text-sm font-bold border-b-2 border-transparent text-slate-500 hover:text-slate-800 flex items-center gap-2 transition-all" data-tab="tab-update">
<i class="fa-solid fa-cloud-arrow-down"></i>
<span>2. Cập Nhật Phiên Bản (Gitea)</span>
</button>
<button class="admin-tab px-5 py-2.5 text-sm font-bold border-b-2 border-transparent text-slate-500 hover:text-slate-800 flex items-center gap-2 transition-all" data-tab="tab-logs">
<i class="fa-solid fa-terminal"></i>
<span>3. Nhật Ký Hoạt Động (Live Logs)</span>
</button>
</div>
</section>
<!-- MAIN CONTAINER -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 py-6 flex-1 w-full">
<!-- ========================================== -->
<!-- TAB 1: CẤU HÌNH KẾT NỐI -->
<!-- ========================================== -->
<div id="tab-config" class="admin-content space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Server & PostgreSQL Config Form -->
<div class="lg:col-span-6 bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-5 py-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-server text-indigo-600"></i>
<span>Cấu Hình Web Server &amp; CSDL PostgreSQL</span>
</h2>
<span class="text-[11px] bg-emerald-100 text-emerald-800 px-2 py-0.5 rounded font-bold">Mã hóa AES-256</span>
</div>
<form id="formConfig" class="p-5 space-y-4 text-xs">
<!-- Server Settings -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-700 mb-1">Cổng Web (Port):</label>
<input type="number" id="cfgPort" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-bold" value="3000">
</div>
<div>
<label class="block font-semibold text-slate-700 mb-1">Đường dẫn quản trị:</label>
<input type="text" id="cfgAdminPath" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500" value="/admin">
</div>
</div>
<!-- PostgreSQL Settings -->
<div class="border-t border-slate-200 pt-3 space-y-3">
<div class="font-bold text-slate-800 text-xs flex items-center gap-1.5">
<i class="fa-solid fa-database text-blue-600"></i> Thông tin máy chủ PostgreSQL:
</div>
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label class="block font-medium text-slate-600 mb-1">Địa chỉ IP / Host:</label>
<input type="text" id="cfgDbHost" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium" value="192.168.50.79">
</div>
<div>
<label class="block font-medium text-slate-600 mb-1">Port:</label>
<input type="number" id="cfgDbPort" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium" value="5437">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-medium text-slate-600 mb-1">Người dùng (User):</label>
<input type="text" id="cfgDbUser" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium" value="postgres">
</div>
<div>
<label class="block font-medium text-slate-600 mb-1 flex items-center justify-between">
<span>Mật khẩu:</span>
<button type="button" id="btnToggleDbPass" class="text-[10px] text-indigo-600 hover:underline">Hiện</button>
</label>
<input type="password" id="cfgDbPassword" placeholder="•••••••• (Giữ nguyên hoặc nhập mới)" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium">
</div>
</div>
<div>
<label class="block font-medium text-slate-600 mb-1">CSDL mặc định (Default DB):</label>
<input type="text" id="cfgDbDefault" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium" value="an_omon_full_04052026">
</div>
</div>
<!-- Test Connection Button -->
<div class="pt-2 flex items-center justify-between">
<button type="button" id="btnTestConnection" class="bg-slate-800 hover:bg-slate-900 text-white font-semibold py-2 px-3.5 rounded-lg flex items-center gap-1.5 transition-all">
<i class="fa-solid fa-bolt text-amber-400"></i>
<span>Thử Kết Nối (Test Connection)</span>
</button>
<div id="testConnResult" class="text-xs font-semibold"></div>
</div>
<!-- Save Button -->
<div class="pt-4 border-t border-slate-200 flex justify-end">
<button type="submit" id="btnSaveConfig" class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2.5 px-6 rounded-lg shadow-md flex items-center gap-2 transition-all text-xs">
<i class="fa-solid fa-floppy-disk"></i>
<span>Lưu Cấu Hình &amp; Mã Hóa</span>
</button>
</div>
</form>
</div>
<!-- Databases List Management -->
<div class="lg:col-span-6 bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden flex flex-col">
<div class="px-5 py-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-list text-blue-600"></i>
<span>Danh Sách CSDL Bệnh Viện (Dropdown)</span>
</h2>
<button id="btnAddDbRow" class="bg-blue-50 text-blue-700 hover:bg-blue-100 border border-blue-200 px-2.5 py-1 rounded-lg text-xs font-bold flex items-center gap-1">
<i class="fa-solid fa-plus"></i> Thêm CSDL
</button>
</div>
<div class="p-4 flex-1 overflow-y-auto">
<table class="w-full text-left text-xs border-collapse">
<thead class="bg-slate-100 text-slate-700 font-semibold border-b border-slate-200">
<tr>
<th class="py-2 px-2.5 w-44">Tên CSDL (DB Name)</th>
<th class="py-2 px-2.5">Tên Hiển Thị (Label)</th>
<th class="py-2 px-2 w-16 text-center">Xóa</th>
</tr>
</thead>
<tbody id="bodyDbList" class="divide-y divide-slate-200">
<!-- Dynamically loaded -->
</tbody>
</table>
</div>
<div class="p-3 bg-amber-50/70 border-t border-amber-200 text-amber-800 text-[11px] leading-relaxed">
<i class="fa-solid fa-circle-info mr-1"></i> 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.
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- TAB 2: CẬP NHẬT PHIÊN BẢN (GITEA) -->
<!-- ========================================== -->
<div id="tab-update" class="admin-content hidden space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Gitea Config -->
<div class="lg:col-span-5 bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-5 py-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-gear text-indigo-600"></i>
<span>Thiết Lập Gitea Package / Release</span>
</h2>
</div>
<form id="formGiteaConfig" class="p-5 space-y-3.5 text-xs">
<div>
<label class="block font-semibold text-slate-700 mb-1">Gitea URL:</label>
<input type="text" id="cfgGiteaUrl" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium" value="https://i.dh-his.com">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-700 mb-1">Tổ chức / Owner:</label>
<input type="text" id="cfgGiteaOwner" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500" value="ongtrieuhau">
</div>
<div>
<label class="block font-semibold text-slate-700 mb-1">Repository:</label>
<input type="text" id="cfgGiteaRepo" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500" value="web-giao-ban">
</div>
</div>
<div>
<label class="block font-semibold text-slate-700 mb-1 flex items-center justify-between">
<span>Personal Access Token (Để tải bản private):</span>
<button type="button" id="btnToggleToken" class="text-[10px] text-indigo-600 hover:underline">Hiện</button>
</label>
<input type="password" id="cfgGiteaToken" placeholder="•••••••• (Giữ nguyên hoặc nhập token mới)" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div class="pt-2 flex justify-end">
<button type="submit" class="bg-slate-800 hover:bg-slate-900 text-white font-bold py-2 px-4 rounded-lg flex items-center gap-1.5 transition-all">
<i class="fa-solid fa-check"></i>
<span>Lưu Cấu Hình Gitea</span>
</button>
</div>
</form>
</div>
<!-- Release Status & Action -->
<div class="lg:col-span-7 bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden flex flex-col">
<div class="px-5 py-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-code-compare text-blue-600"></i>
<span>Trạng Thái Phiên Bản &amp; Tự Động Cập Nhật</span>
</h2>
<button id="btnCheckUpdate" class="bg-indigo-600 hover:bg-indigo-700 text-white px-3.5 py-1.5 rounded-lg text-xs font-bold shadow-sm flex items-center gap-1.5 transition-all">
<i class="fa-solid fa-arrows-rotate"></i>
<span>Kiểm Tra Cập Nhật</span>
</button>
</div>
<div class="p-6 flex-1 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="p-4 rounded-xl border border-slate-200 bg-slate-50">
<div class="text-xs text-slate-500 font-semibold">Phiên Bản Đang Chạy:</div>
<div class="mt-1 text-2xl font-black text-slate-800" id="currentVerDisplay">v1.0.0</div>
</div>
<div class="p-4 rounded-xl border border-indigo-200 bg-indigo-50/40">
<div class="text-xs text-indigo-700 font-semibold">Phiên Bản Mới Nhất Trên Gitea:</div>
<div class="mt-1 text-2xl font-black text-indigo-700" id="latestVerDisplay">Đang kiểm tra...</div>
</div>
</div>
<div id="updateStatusBox" class="p-4 rounded-xl border border-slate-200 bg-white">
<div class="font-bold text-xs text-slate-700 mb-1" id="updateStatusTitle">Nội dung phiên bản:</div>
<div class="text-xs text-slate-600 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto" id="updateReleaseNotes">
Bấm nút "Kiểm Tra Cập Nhật" để truy vấn phiên bản mới nhất từ Gitea.
</div>
</div>
<!-- Action Button Apply Update -->
<div id="applyUpdateArea" class="hidden p-4 rounded-xl bg-emerald-50 border border-emerald-200 flex items-center justify-between gap-3">
<div>
<div class="font-bold text-xs text-emerald-900">Phát hiện bản cập nhật mới!</div>
<div class="text-[11px] text-emerald-700" id="updateAssetInfo">File: ...</div>
</div>
<button id="btnApplyUpdate" class="bg-emerald-600 hover:bg-emerald-700 text-white px-5 py-2.5 rounded-lg font-bold text-xs shadow-md flex items-center gap-2 transition-all">
<i class="fa-solid fa-download"></i>
<span>Tải &amp; Cập Nhật Tự Động</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- TAB 3: NHẬT KÝ HOẠT ĐỘNG (LIVE LOGS) -->
<!-- ========================================== -->
<div id="tab-logs" class="admin-content hidden space-y-4">
<!-- Toolbar Logs -->
<div class="bg-white p-3.5 rounded-2xl shadow-sm border border-slate-200 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 text-xs">
<span class="font-bold text-slate-700 flex items-center gap-1.5">
<i class="fa-solid fa-filter text-indigo-600"></i> Cấp độ log:
</span>
<select id="selectLogLevel" class="rounded-lg border border-slate-300 px-3 py-1.5 bg-slate-50 outline-none font-medium">
<option value="ALL">Tất cả (ALL)</option>
<option value="INFO">Thông tin (INFO)</option>
<option value="WARN">Cảnh báo (WARN)</option>
<option value="ERROR">Lỗi (ERROR)</option>
<option value="SQL">Truy vấn (SQL)</option>
</select>
<label class="inline-flex items-center gap-1.5 cursor-pointer text-slate-600 font-medium">
<input type="checkbox" id="chkAutoRefresh" class="rounded text-indigo-600" checked>
<span>Tự động cập nhật (3s)</span>
</label>
</div>
<div class="flex items-center gap-2">
<button id="btnRefreshLogs" class="bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold px-3 py-1.5 rounded-lg border border-slate-300 flex items-center gap-1">
<i class="fa-solid fa-rotate-right"></i> Làm mới
</button>
<button id="btnClearLogs" class="bg-rose-50 hover:bg-rose-100 text-rose-700 border border-rose-200 text-xs font-semibold px-3 py-1.5 rounded-lg flex items-center gap-1">
<i class="fa-solid fa-trash-can"></i> Xóa log
</button>
</div>
</div>
<!-- Log Terminal Box -->
<div class="bg-slate-950 rounded-2xl p-4 shadow-xl border border-slate-800 font-mono text-xs overflow-hidden flex flex-col h-[600px]">
<div class="flex items-center justify-between pb-2 border-b border-slate-800 text-slate-500 text-[11px]">
<span>TERMINAL LOG STREAM (Ring buffer 500 dòng)</span>
<span id="logCountBadge" class="text-slate-400">0 dòng</span>
</div>
<div id="logTerminal" class="flex-1 overflow-y-auto space-y-1 mt-2 text-slate-300 leading-relaxed pr-2">
<div class="text-slate-500 italic">Đang tải nhật ký...</div>
</div>
</div>
</div>
</main>
<!-- TOAST -->
<div id="toast" class="fixed bottom-4 right-4 z-50 bg-slate-900 text-white text-xs px-4 py-2.5 rounded-lg shadow-xl flex items-center gap-2 transform translate-y-20 opacity-0 transition-all duration-300">
<i class="fa-solid fa-circle-check text-emerald-400"></i>
<span id="toastMsg">Thông báo</span>
</div>
<script src="admin.js"></script>
</body>
</html>
+448
View File
@@ -0,0 +1,448 @@
// 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';
const owner = (cfg.updater.owner && cfg.updater.owner !== 'hdhiswork') ? cfg.updater.owner : 'ongtrieuhau';
const repo = (cfg.updater.repo && cfg.updater.repo !== 'DUAN') ? cfg.updater.repo : 'web-giao-ban';
document.getElementById('cfgGiteaOwner').value = owner;
document.getElementById('cfgGiteaRepo').value = repo;
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 = `
<td class="py-2 px-2.5">
<input type="text" class="w-full rounded border border-slate-300 px-2 py-1 font-mono text-xs db-row-id" value="${id}" placeholder="Tên CSDL trong Postgres">
</td>
<td class="py-2 px-2.5">
<input type="text" class="w-full rounded border border-slate-300 px-2 py-1 text-xs db-row-name" value="${name}" placeholder="Tên hiển thị dropdown">
</td>
<td class="py-2 px-2 text-center">
<button type="button" class="text-rose-500 hover:text-rose-700 p-1 text-xs btn-del-row" title="Xóa dòng">
<i class="fa-solid fa-trash-can"></i>
</button>
</td>
`;
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 = '<span class="text-blue-600"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Đang thử kết nối...</span>';
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 = '<span class="text-emerald-600"><i class="fa-solid fa-circle-check mr-1"></i> Kết nối thành công!</span>';
} else {
resEl.innerHTML = `<span class="text-rose-600"><i class="fa-solid fa-circle-xmark mr-1"></i> ${json.message}</span>`;
}
} catch (err) {
resEl.innerHTML = `<span class="text-rose-600"><i class="fa-solid fa-circle-xmark mr-1"></i> ${err.message}</span>`;
}
});
// Save Config Form
document.getElementById('formConfig')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('btnSaveConfig');
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> <span>Đang lưu...</span>';
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 = '<i class="fa-solid fa-floppy-disk"></i> <span>Lưu Cấu Hình &amp; Mã Hóa</span>';
}
});
// 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 = '<div class="text-slate-500 italic">Không có dòng log nào.</div>';
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 = `
<span class="text-slate-500 text-[10px] whitespace-nowrap">${entry.timestamp}</span>
<span class="text-[10px] font-bold px-1.5 rounded ${badgeColor}">${entry.level}</span>
<span class="${colorClass} break-all flex-1">${escapeHtml(entry.message)}</span>
`;
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 = '<i class="fa-solid fa-spinner fa-spin"></i> <span>Đang kiểm tra...</span>';
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 = `<span class="text-emerald-600 font-bold"><i class="fa-solid fa-bell"></i> Có bản cập nhật mới: ${u.release_title || u.latest_version}</span>`;
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 = `<span class="text-slate-600 font-bold"><i class="fa-solid fa-circle-check text-emerald-500"></i> Bạn đang sử dụng phiên bản mới nhất (${u.current_version})</span>`;
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 = '<i class="fa-solid fa-arrows-rotate"></i> <span>Kiểm Tra Cập Nhật</span>';
}
}
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 = '<i class="fa-solid fa-spinner fa-spin mr-1"></i> Đ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 = '<i class="fa-solid fa-download"></i> <span>Tải &amp; Cập Nhật Tự Động</span>';
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
+1544
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+907
View File
@@ -0,0 +1,907 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DH-HIS | Quản Lý Giao Ban Hằng Ngày</title>
<link rel="icon" type="image/x-icon" href="favicon.ico">
<!-- Tailwind CSS via CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Chart.js via CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="styles.css">
</head>
<body class="bg-slate-100 text-slate-800 font-sans antialiased">
<!-- TOP HEADER -->
<header class="bg-gradient-to-r from-blue-700 via-sky-700 to-indigo-800 text-white shadow-lg sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="bg-white/10 p-2 rounded-lg backdrop-blur-sm border border-white/20">
<i class="fa-solid fa-hospital-user text-2xl text-cyan-300"></i>
</div>
<div>
<h1 class="text-xl font-bold tracking-tight uppercase flex items-center gap-2 flex-wrap">
Phần Mềm Quản Lý Giao Ban Bệnh Viện
<span class="text-xs bg-cyan-500/30 text-cyan-200 border border-cyan-400/40 px-2 py-0.5 rounded-full font-normal">DH-HIS Web</span>
<span id="currentDbBadge" class="text-xs bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 px-2.5 py-0.5 rounded-full font-medium flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span> BVĐK Ô Môn
</span>
</h1>
<p class="text-xs text-sky-200">Báo cáo Giao ban Ngoại trú &amp; Nội trú hằng ngày - Phân tích đối chiếu đa chu kỳ</p>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Database Selector -->
<div class="flex items-center gap-2 text-xs bg-slate-900/40 px-3 py-1.5 rounded-lg border border-white/10">
<label for="selectDb" class="text-sky-200 font-medium flex items-center gap-1 whitespace-nowrap">
<i class="fa-solid fa-database text-amber-400"></i> CSDL:
</label>
<select id="selectDb" class="bg-slate-800 text-white rounded px-2 py-1 outline-none border border-slate-600 focus:border-cyan-400 text-xs font-medium cursor-pointer">
<option value="an_omon_full_04052026">BVĐK Ô Môn - Cần Thơ (33 GB - Chuẩn khoa phòng)</option>
<option value="tuan_dbgovap_full">BVĐK Gò Vấp - TP.HCM (65 GB - Dữ liệu lớn)</option>
<option value="binh_bvq12_062026052026042026032026022026">BV Quận 12 - TP.HCM (21 GB)</option>
<option value="bvq12_697">BV Quận 12 - Bản 697 (17 GB)</option>
<option value="an_dalieu_full_05082026">BV Da Liễu (31 GB)</option>
</select>
</div>
<!-- Admin Config Link -->
<a href="admin.html?v=1.0.2" class="text-xs bg-slate-900/40 hover:bg-slate-800 text-sky-200 hover:text-white px-2.5 py-2 rounded-lg border border-white/10 flex items-center gap-1.5 transition-all" title="Quản trị &amp; Cấu hình hệ thống">
<i class="fa-solid fa-sliders text-cyan-300"></i>
<span class="hidden sm:inline">Quản Trị</span>
</a>
</div>
</div>
</header>
<!-- CONTROL PANEL / FILTER TOOLBAR -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 mt-4">
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-4 transition-all">
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 items-end">
<!-- Mốc thời gian chọn nhanh -->
<div class="md:col-span-3">
<label class="block text-xs font-semibold text-slate-600 mb-1">
<i class="fa-solid fa-calendar-check text-blue-600"></i> Chọn nhanh mốc thời gian:
</label>
<select id="quickPreset" class="w-full text-sm rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500 font-medium">
<option value="month-03-2026" selected>Tháng 03/2026 (Dữ liệu đầy đủ đối chiếu)</option>
<option value="month-04-2026">Tháng 04/2026</option>
<option value="month-01-2026">Tháng 01/2026</option>
<option value="month-02-2026">Tháng 02/2026</option>
<option value="today">Hôm nay</option>
<option value="yesterday">Hôm qua</option>
<option value="this-month">Tháng hiện tại</option>
<option value="quarter-1">Quý I/2026</option>
<option value="year-2026">Cả năm 2026</option>
<option value="custom">Tùy chọn khoảng ngày...</option>
</select>
</div>
<!-- Kỳ chính: Từ ngày - Đến ngày -->
<div class="md:col-span-4 flex items-center gap-2">
<div class="w-1/2">
<label class="block text-xs font-semibold text-slate-600 mb-1">Từ ngày:</label>
<input type="date" id="dateFrom" class="w-full text-sm rounded-lg border border-slate-300 px-2.5 py-1.5 bg-slate-50 outline-none focus:ring-2 focus:ring-blue-500" value="2026-03-01">
</div>
<div class="w-1/2">
<label class="block text-xs font-semibold text-slate-600 mb-1">Đến ngày:</label>
<input type="date" id="dateTo" class="w-full text-sm rounded-lg border border-slate-300 px-2.5 py-1.5 bg-slate-50 outline-none focus:ring-2 focus:ring-blue-500" value="2026-03-31">
</div>
</div>
<!-- Checkbox so sánh -->
<div class="md:col-span-2 flex flex-col justify-end pb-1.5">
<label class="inline-flex items-center gap-2 cursor-pointer select-none text-xs font-semibold text-indigo-700 bg-indigo-50 border border-indigo-200 px-3 py-2 rounded-lg hover:bg-indigo-100 transition-colors">
<input type="checkbox" id="enableCompare" class="rounded text-indigo-600 focus:ring-indigo-500 h-4 w-4">
<span>So sánh 2 kỳ</span>
</label>
</div>
<!-- Action Buttons -->
<div class="md:col-span-3 flex items-center gap-2">
<button id="btnFilter" class="flex-1 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold py-2 px-3 rounded-lg shadow-sm flex items-center justify-center gap-1.5 transition-all">
<i class="fa-solid fa-magnifying-glass"></i>
<span>Xem Báo Cáo</span>
</button>
<button id="btnOpenIssueModal" class="bg-rose-600 hover:bg-rose-700 text-white text-sm font-semibold py-2 px-3 rounded-lg shadow-sm flex items-center gap-1.5 transition-all" title="Báo cáo lỗi / Góp ý">
<i class="fa-solid fa-bug"></i>
<span class="hidden sm:inline">Báo Lỗi</span>
</button>
<button id="btnPrint" class="bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm font-semibold py-2 px-2.5 rounded-lg border border-slate-300" title="In báo cáo">
<i class="fa-solid fa-print"></i>
</button>
<button id="btnExport" class="bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold py-2 px-2.5 rounded-lg shadow-sm" title="Xuất dữ liệu Excel">
<i class="fa-solid fa-file-excel"></i>
</button>
</div>
</div>
<!-- KỲ SO SÁNH (Hiển thị khi bật enableCompare) -->
<div id="compareContainer" class="hidden mt-3 pt-3 border-t border-dashed border-indigo-200 bg-indigo-50/50 p-3 rounded-lg">
<div class="flex flex-wrap items-center gap-4">
<span class="text-xs font-bold text-indigo-900 flex items-center gap-1.5">
<i class="fa-solid fa-code-compare text-indigo-600"></i> Kỳ so sánh (Ví dụ: Tháng 01/2026):
</span>
<div class="flex items-center gap-2">
<label class="text-xs text-slate-600 font-medium">Từ:</label>
<input type="date" id="compareFrom" class="text-sm rounded border border-indigo-300 px-2 py-1 bg-white outline-none" value="2026-01-01">
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-slate-600 font-medium">Đến:</label>
<input type="date" id="compareTo" class="text-sm rounded border border-indigo-300 px-2 py-1 bg-white outline-none" value="2026-01-31">
</div>
<span class="text-xs text-slate-500 italic">* Hệ thống sẽ đối chiếu song song và vẽ biểu đồ cột tăng giảm tự động.</span>
</div>
</div>
</div>
</section>
<!-- NAVIGATION TABS -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 mt-4">
<div class="flex border-b border-slate-200 gap-2 flex-wrap">
<button class="tab-btn active px-4 py-2.5 text-sm font-bold border-b-2 border-blue-600 text-blue-600 flex items-center gap-2 transition-all" data-tab="tab-ngoaitru">
<i class="fa-solid fa-user-doctor"></i>
<span>1. Báo Cáo Giao Ban Ngoại Trú</span>
</button>
<button class="tab-btn px-4 py-2.5 text-sm font-bold border-b-2 border-transparent text-slate-500 hover:text-slate-800 flex items-center gap-2 transition-all" data-tab="tab-noitru">
<i class="fa-solid fa-bed-pulse"></i>
<span>2. Báo Cáo Giao Ban Nội Trú</span>
</button>
<button class="tab-btn px-4 py-2.5 text-sm font-bold border-b-2 border-transparent text-slate-500 hover:text-slate-800 flex items-center gap-2 transition-all" data-tab="tab-issues">
<i class="fa-solid fa-clipboard-list text-rose-500"></i>
<span>3. Quản Lý Issue &amp; Báo Lỗi</span>
<span id="badgeIssueCount" class="ml-1 px-1.5 py-0.2 text-[10px] font-bold rounded-full bg-rose-100 text-rose-700 border border-rose-300">0</span>
</button>
</div>
</section>
<!-- MAIN CONTENT CONTAINER -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 py-4">
<!-- ========================================== -->
<!-- TAB 1: BÁO CÁO GIAO BAN NGOẠI TRÚ -->
<!-- ========================================== -->
<div id="tab-ngoaitru" class="tab-content space-y-4">
<!-- KPI METRIC CARDS -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Tổng Khám</span>
<i class="fa-solid fa-stethoscope text-blue-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-blue-700" id="kpiTongKham">0</div>
<div class="text-[11px] text-slate-400 mt-1">Lượt khám hoàn tất</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Khám BHYT</span>
<i class="fa-solid fa-id-card text-emerald-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-emerald-600" id="kpiBhyt">0</div>
<div class="text-[11px] text-emerald-600/80 font-medium mt-1" id="kpiBhytPct">0% tổng lượt</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Khám Thu Phí</span>
<i class="fa-solid fa-receipt text-amber-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-amber-600" id="kpiThuPhi">0</div>
<div class="text-[11px] text-amber-600/80 font-medium mt-1" id="kpiThuPhiPct">0% tổng lượt</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Chuyển Viện</span>
<i class="fa-solid fa-truck-medical text-rose-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-rose-600" id="kpiChuyenVien">0</div>
<div class="text-[11px] text-rose-600/80 font-medium mt-1">Tuyến trên &amp; ngang</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Tiêm Ngừa</span>
<i class="fa-solid fa-syringe text-purple-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-purple-600" id="kpiTiemNgua">0</div>
<div class="text-[11px] text-slate-400 mt-1">Lượt tiêm chủng</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Bệnh Án Ngoại Trú</span>
<i class="fa-solid fa-folder-open text-sky-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-sky-700" id="kpiBantTong">0</div>
<div class="text-[11px] text-slate-500 mt-1 flex justify-between">
<span>Mới: <b class="text-sky-600" id="kpiBantMoi">0</b></span>
<span>Tái khám: <b class="text-sky-600" id="kpiBantTk">0</b></span>
</div>
</div>
</div>
<!-- ROW 1: BẢNG KHÁM THEO KHOA & SỐ LIỆU CHUYÊN KHOA YDCT -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4">
<!-- Bảng khám chữa bệnh theo Khoa -->
<div class="lg:col-span-8 bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-4 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-table-list text-blue-600"></i>
<span>1.1. Số Liệu Khám Chữa Bệnh Theo Các Khoa</span>
</h2>
<span class="text-xs text-slate-500" id="subKhoaKcb">Khám bệnh, Nội, Ngoại, CSSKSS, Nhi, YDCT</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm border-collapse" id="tableKhoaKcb">
<thead class="bg-slate-100 text-slate-700 font-semibold text-xs uppercase tracking-wider border-b border-slate-200">
<tr>
<th class="py-2.5 px-3">Mã</th>
<th class="py-2.5 px-3">Khoa Khám Bệnh</th>
<th class="py-2.5 px-3 text-right">BHYT</th>
<th class="py-2.5 px-3 text-right">Thu Phí</th>
<th class="py-2.5 px-3 text-right">Chuyển Viện</th>
<th class="py-2.5 px-3 text-right">Tổng Khám</th>
<!-- Cột so sánh nếu bật -->
<th class="py-2.5 px-3 text-right th-compare hidden">Kỳ So Sánh</th>
<th class="py-2.5 px-3 text-right th-compare hidden">Chênh Lệch</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 font-normal text-slate-800" id="bodyKhoaKcb">
<tr><td colspan="6" class="text-center py-6 text-slate-400">Đang tải dữ liệu...</td></tr>
</tbody>
<tfoot class="bg-slate-100 font-bold text-slate-800 text-xs border-t-2 border-slate-300" id="footKhoaKcb">
</tfoot>
</table>
</div>
</div>
<!-- Chuyên khoa YDCT & Bệnh án ngoại trú Card -->
<div class="lg:col-span-4 space-y-4">
<!-- Card YDCT -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-4 py-3 bg-amber-50/70 border-b border-amber-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-amber-900 flex items-center gap-2">
<i class="fa-solid fa-leaf text-amber-600"></i>
<span>1.2. Chuyên Khoa Y Dược Cổ Truyền</span>
</h2>
<span class="text-xs bg-amber-200/60 text-amber-800 px-2 py-0.5 rounded font-semibold">YDCT</span>
</div>
<div class="p-4 space-y-3">
<div class="flex items-center justify-between border-b border-slate-100 pb-2">
<span class="text-sm text-slate-600 font-medium flex items-center gap-2">
<i class="fa-solid fa-bolt text-amber-500"></i> Điện châm
</span>
<span class="text-base font-bold text-slate-800" id="ydctDienCham">0</span>
</div>
<div class="flex items-center justify-between border-b border-slate-100 pb-2">
<span class="text-sm text-slate-600 font-medium flex items-center gap-2">
<i class="fa-solid fa-person-walking text-blue-500"></i> Vật lý trị liệu (VLTL)
</span>
<span class="text-base font-bold text-slate-800" id="ydctVltl">0</span>
</div>
<div class="flex items-center justify-between border-b border-slate-100 pb-2">
<span class="text-sm text-slate-600 font-medium flex items-center gap-2">
<i class="fa-solid fa-mug-hot text-emerald-500"></i> Sắc thuốc thang
</span>
<span class="text-base font-bold text-slate-800" id="ydctSacThuoc">0</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-slate-600 font-medium flex items-center gap-2">
<i class="fa-solid fa-certificate text-purple-500"></i> Cấy chỉ (Chôn chỉ)
</span>
<span class="text-base font-bold text-slate-800" id="ydctCayChi">0</span>
</div>
</div>
</div>
<!-- Top 10 Bệnh Ngoại Trú -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-4 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-ranking-star text-rose-500"></i>
<span>Top 10 Chẩn Đoán Ngoại Trú</span>
</h2>
</div>
<div class="p-2 overflow-y-auto max-h-72">
<table class="w-full text-left text-xs border-collapse">
<tbody class="divide-y divide-slate-100 font-medium" id="bodyTop10">
<tr><td class="text-center py-4 text-slate-400">Đang tải dữ liệu...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ROW 2: TỔNG HỢP CẬN LÂM SÀNG & BIỂU ĐỒ CỘT SO SÁNH -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4">
<!-- Bảng Cận Lâm Sàng -->
<div class="lg:col-span-6 bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-4 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-flask-vial text-cyan-600"></i>
<span>1.3. Tổng Hợp Số Liệu Cận Lâm Sàng</span>
</h2>
<span class="text-xs text-slate-500">Xét nghiệm &amp; CĐHA</span>
</div>
<div class="overflow-x-auto max-h-96">
<table class="w-full text-left text-xs border-collapse">
<thead class="bg-slate-100 text-slate-700 font-semibold uppercase border-b border-slate-200 sticky top-0">
<tr>
<th class="py-2 px-3">Phân nhóm</th>
<th class="py-2 px-3">Loại Kỹ Thuật</th>
<th class="py-2 px-3 text-right">Số Bệnh Nhân</th>
<th class="py-2 px-3 text-right">Số Lượt Thực Hiện</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100" id="bodyCls">
<tr><td colspan="4" class="text-center py-4 text-slate-400">Đang tải dữ liệu...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Biểu đồ cột so sánh -->
<div class="lg:col-span-6 bg-white rounded-xl shadow-sm border border-slate-200 p-4 flex flex-col justify-between">
<div class="flex items-center justify-between border-b border-slate-100 pb-2 mb-2">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-chart-column text-indigo-600"></i>
<span>Biểu Đồ Cột So Sánh Số Liệu</span>
</h2>
<div class="flex gap-1 text-xs">
<button class="chart-type-btn active px-2.5 py-1 rounded bg-blue-100 text-blue-700 font-semibold" data-chart="khoa">Theo Khoa</button>
<button class="chart-type-btn px-2.5 py-1 rounded bg-slate-100 text-slate-600 font-medium" data-chart="cls">Theo CLS</button>
</div>
</div>
<div class="relative w-full h-80">
<canvas id="chartNgoaiTru"></canvas>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- TAB 2: BÁO CÁO GIAO BAN NỘI TRÚ -->
<!-- ========================================== -->
<div id="tab-noitru" class="tab-content hidden space-y-4">
<!-- KPI METRICS NỘI TRÚ -->
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Bệnh Cũ (Tồn Đầu)</span>
<i class="fa-solid fa-clock-rotate-left text-slate-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-slate-700" id="kpiNtBenhCu">0</div>
<div class="text-[11px] text-slate-400 mt-1">Chuyển từ kỳ trước</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Bệnh Mới (Vào Viện)</span>
<i class="fa-solid fa-user-plus text-blue-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-blue-600" id="kpiNtBenhMoi">0</div>
<div class="text-[11px] text-blue-600/80 font-medium mt-1">Nhập viện trong kỳ</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Tổng Xuất Viện</span>
<i class="fa-solid fa-person-walking-arrow-right text-emerald-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-emerald-600" id="kpiNtXuatVien">0</div>
<div class="text-[11px] text-emerald-600/80 font-medium mt-1" id="kpiNtOnPct">Ổn về: 0 ca</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Bệnh Hiện Còn</span>
<i class="fa-solid fa-bed text-indigo-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-indigo-700" id="kpiNtHienCon">0</div>
<div class="text-[11px] text-indigo-700/80 font-medium mt-1">Đang nằm điều trị</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Bệnh Theo Dõi</span>
<i class="fa-solid fa-heart-pulse text-rose-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-rose-600" id="kpiNtTheoDoi">0</div>
<div class="text-[11px] text-rose-600/80 font-medium mt-1">Bệnh nhân nặng</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-col justify-between">
<div class="flex items-center justify-between text-slate-500">
<span class="text-xs font-semibold">Chăm Sóc Cấp I</span>
<i class="fa-solid fa-hand-holding-medical text-amber-500"></i>
</div>
<div class="mt-2 text-2xl font-extrabold text-amber-600" id="kpiNtCsCap1">0</div>
<div class="text-[11px] text-amber-600/80 font-medium mt-1">Chế độ chăm sóc I</div>
</div>
</div>
<!-- BẢNG 1: LƯU LƯỢNG BỆNH NHÂN THEO CÁC KHOA -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-4 py-3 bg-slate-50 border-b border-slate-200 flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-hospital text-blue-600"></i>
<span>2.1. Tổng Hợp Lưu Lượng Bệnh Nhân Theo Các Khoa Nội Trú</span>
</h2>
<p class="text-xs text-slate-500">Cấp cứu HS, Nội, Nhi, Ngoại TH, CSSKSS, YDCT,... (Khớp công thức: Tồn đầu + Vào viện - Ra viện = Hiện còn)</p>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs border-collapse" id="tableNoiTru">
<thead class="bg-slate-100 text-slate-700 font-semibold uppercase tracking-wider border-b border-slate-200">
<tr>
<th class="py-2.5 px-3">Mã</th>
<th class="py-2.5 px-3">Khoa Điều Trị</th>
<th class="py-2.5 px-3 text-right">Bệnh Cũ</th>
<th class="py-2.5 px-3 text-right">Bệnh Mới</th>
<th class="py-2.5 px-3 text-right text-emerald-700">Ổn xin về</th>
<th class="py-2.5 px-3 text-right text-amber-700">Nặng về</th>
<th class="py-2.5 px-3 text-right text-slate-600">Trốn</th>
<th class="py-2.5 px-3 text-right text-rose-700">Chuyển viện</th>
<th class="py-2.5 px-3 text-right text-rose-900">Tử vong</th>
<th class="py-2.5 px-3 text-right font-bold text-blue-700 bg-blue-50/50">Hiện Còn</th>
<th class="py-2.5 px-3 text-right">Bệnh Theo Dõi</th>
<th class="py-2.5 px-3 text-right">CS Cấp I</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 font-normal text-slate-800" id="bodyNoiTru">
<tr><td colspan="12" class="text-center py-6 text-slate-400">Đang tải dữ liệu...</td></tr>
</tbody>
<tfoot class="bg-slate-100 font-bold text-slate-800 text-xs border-t-2 border-slate-300" id="footNoiTru">
</tfoot>
</table>
</div>
</div>
<!-- BIỂU ĐỒ CỘT SO SÁNH NỘI TRÚ -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-4">
<div class="flex items-center justify-between border-b border-slate-100 pb-2 mb-3">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-chart-column text-indigo-600"></i>
<span>Biểu Đồ Lưu Lượng Bệnh Nhân &amp; So Sánh Đa Chu Kỳ</span>
</h2>
<span class="text-xs text-slate-500">So sánh số bệnh mới nhập viện giữa các khoa</span>
</div>
<div class="relative w-full h-80">
<canvas id="chartNoiTru"></canvas>
</div>
</div>
<!-- BẢNG 3: DANH SÁCH BỆNH NHÂN CHUYỂN VIỆN -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-4 py-3 bg-rose-50/80 border-b border-rose-200 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-sm font-bold text-rose-900 flex items-center gap-2">
<i class="fa-solid fa-ambulance text-rose-600"></i>
<span>BẢNG 3: Danh Sách Bệnh Nhân Chuyển Viện</span>
</h2>
<p class="text-xs text-rose-700/80">Cho phép các khoa tự nhập liệu trực tiếp vào cột Ghi chú trên lưới trước giờ họp giao ban</p>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-slate-600 font-medium">Khoa:</span>
<select id="filterKhoaCv" class="rounded border border-slate-300 px-2 py-1 bg-white outline-none">
<option value="ALL">Tất cả khoa</option>
</select>
<span class="bg-rose-200 text-rose-800 px-2 py-0.5 rounded-full font-bold text-[11px]" id="countBang3">0 ca</span>
</div>
</div>
<div class="overflow-x-auto max-h-[500px]">
<table class="w-full text-left text-xs border-collapse">
<thead class="bg-slate-100 text-slate-700 font-semibold uppercase sticky top-0 border-b border-slate-200 z-10">
<tr>
<th class="py-2.5 px-2.5 w-10 text-center">STT</th>
<th class="py-2.5 px-2.5 w-24">Mã BN / BA</th>
<th class="py-2.5 px-3 w-40">Họ và Tên</th>
<th class="py-2.5 px-3 w-48">Địa Chỉ</th>
<th class="py-2.5 px-3 w-32">Ngày Vào / Ra</th>
<th class="py-2.5 px-3 w-32">Khoa Chuyển</th>
<th class="py-2.5 px-3 w-56">Chẩn Đoán (Chính/Phụ)</th>
<th class="py-2.5 px-3 w-64">Kết Quả CLS Tóm Tắt</th>
<th class="py-2.5 px-3 w-64 bg-amber-50 text-amber-900 font-bold border-l border-amber-200">
<i class="fa-solid fa-pen-to-square"></i> Ghi Chú (Khoa tự nhập)
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200" id="bodyBang3">
<tr><td colspan="9" class="text-center py-6 text-slate-400">Đang tải danh sách chuyển viện...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- BẢNG 4: DANH SÁCH BỆNH NHÂN THEO DÕI & CHĂM SÓC CẤP I -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-4 py-3 bg-amber-50/80 border-b border-amber-200 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-sm font-bold text-amber-900 flex items-center gap-2">
<i class="fa-solid fa-eye text-amber-600"></i>
<span>BẢNG 4: Danh Sách Bệnh Nhân Theo Dõi &amp; Chăm Sóc Cấp I</span>
</h2>
<p class="text-xs text-amber-700/80">Theo dõi diễn biến nặng, can thiệp đặc biệt và chỉ định chăm sóc cấp I</p>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-slate-600 font-medium">Khoa:</span>
<select id="filterKhoaTd" class="rounded border border-slate-300 px-2 py-1 bg-white outline-none">
<option value="ALL">Tất cả khoa</option>
</select>
<span class="bg-amber-200 text-amber-800 px-2 py-0.5 rounded-full font-bold text-[11px]" id="countBang4">0 ca</span>
</div>
</div>
<div class="overflow-x-auto max-h-[500px]">
<table class="w-full text-left text-xs border-collapse">
<thead class="bg-slate-100 text-slate-700 font-semibold uppercase sticky top-0 border-b border-slate-200 z-10">
<tr>
<th class="py-2.5 px-2.5 w-10 text-center">STT</th>
<th class="py-2.5 px-2.5 w-24">Mã BN / BA</th>
<th class="py-2.5 px-3 w-40">Họ và Tên</th>
<th class="py-2.5 px-3 w-48">Địa Chỉ</th>
<th class="py-2.5 px-3 w-32">Ngày Vào Viện</th>
<th class="py-2.5 px-3 w-32">Khoa Điều Trị</th>
<th class="py-2.5 px-3 w-56">Chẩn Đoán (Chính/Phụ)</th>
<th class="py-2.5 px-3 w-64">Kết Quả CLS Tóm Tắt</th>
<th class="py-2.5 px-3 w-64 bg-amber-50 text-amber-900 font-bold border-l border-amber-200">
<i class="fa-solid fa-pen-to-square"></i> Ghi Chú (Khoa tự nhập)
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200" id="bodyBang4">
<tr><td colspan="9" class="text-center py-6 text-slate-400">Đang tải danh sách theo dõi...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- TAB 3: QUẢN LÝ ISSUE & BÁO LỖI -->
<!-- ========================================== -->
<div id="tab-issues" class="tab-content hidden space-y-4">
<!-- KPI METRIC CARDS CHO ISSUE -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between">
<div>
<div class="text-xs font-semibold text-slate-500">Tổng Số Báo Lỗi</div>
<div class="mt-1 text-2xl font-black text-slate-800" id="issueKpiTotal">0</div>
</div>
<div class="w-10 h-10 rounded-lg bg-slate-100 flex items-center justify-center text-slate-600">
<i class="fa-solid fa-list-check text-lg"></i>
</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-amber-200 shadow-sm flex items-center justify-between bg-amber-50/40">
<div>
<div class="text-xs font-semibold text-amber-700">Chờ Xử Lý</div>
<div class="mt-1 text-2xl font-black text-amber-600" id="issueKpiPending">0</div>
</div>
<div class="w-10 h-10 rounded-lg bg-amber-100 flex items-center justify-center text-amber-600">
<i class="fa-solid fa-clock text-lg"></i>
</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-blue-200 shadow-sm flex items-center justify-between bg-blue-50/40">
<div>
<div class="text-xs font-semibold text-blue-700">Đang Xử Lý</div>
<div class="mt-1 text-2xl font-black text-blue-600" id="issueKpiProcessing">0</div>
</div>
<div class="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-blue-600">
<i class="fa-solid fa-screwdriver-wrench text-lg"></i>
</div>
</div>
<div class="bg-white p-3.5 rounded-xl border border-emerald-200 shadow-sm flex items-center justify-between bg-emerald-50/40">
<div>
<div class="text-xs font-semibold text-emerald-700">Đã Giải Quyết</div>
<div class="mt-1 text-2xl font-black text-emerald-600" id="issueKpiResolved">0</div>
</div>
<div class="w-10 h-10 rounded-lg bg-emerald-100 flex items-center justify-center text-emerald-600">
<i class="fa-solid fa-circle-check text-lg"></i>
</div>
</div>
</div>
<!-- FILTER & ACTION TOOLBAR -->
<div class="bg-white p-3 rounded-xl border border-slate-200 shadow-sm flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="font-bold text-slate-700 flex items-center gap-1">
<i class="fa-solid fa-filter text-blue-600"></i> Bộ lọc:
</span>
<select id="filterIssueStatus" class="rounded-lg border border-slate-300 px-2.5 py-1.5 bg-slate-50 outline-none">
<option value="ALL">Tất cả trạng thái</option>
<option value="CHO_XU_LY">Chờ xử lý</option>
<option value="DANG_XU_LY">Đang xử lý</option>
<option value="DA_XU_LY">Đã xử lý</option>
<option value="DONG">Đã đóng</option>
</select>
<select id="filterIssueCategory" class="rounded-lg border border-slate-300 px-2.5 py-1.5 bg-slate-50 outline-none">
<option value="ALL">Tất cả phân loại</option>
<option value="SAI_LECH_SO_LIEU">Sai lệch số liệu</option>
<option value="LOI_GIAO_DIEN">Lỗi giao diện / chức năng</option>
<option value="LOI_KET_NOI">Lỗi kết nối / timeout</option>
<option value="DE_XUAT_CAI_TIEN">Đề xuất cải tiến</option>
<option value="YEU_CAU_MOI">Yêu cầu mới</option>
<option value="KHAC">Khác</option>
</select>
<input type="text" id="filterIssueKeyword" placeholder="Tìm theo mã, tiêu đề, người báo..." class="rounded-lg border border-slate-300 px-2.5 py-1.5 bg-slate-50 outline-none w-56">
</div>
<div class="flex items-center gap-2">
<button id="btnExportIssuesExcel" class="bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold py-1.5 px-3 rounded-lg shadow-sm flex items-center gap-1.5 transition-all">
<i class="fa-solid fa-file-excel"></i>
<span>Xuất Excel Tổng Hợp</span>
</button>
<button id="btnOpenIssueModal2" class="bg-rose-600 hover:bg-rose-700 text-white text-xs font-semibold py-1.5 px-3 rounded-lg shadow-sm flex items-center gap-1.5 transition-all">
<i class="fa-solid fa-plus"></i>
<span>Tạo Báo Lỗi Mới</span>
</button>
</div>
</div>
<!-- BẢNG TỔNG HỢP ISSUE -->
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left text-xs border-collapse" id="tableIssues">
<thead class="bg-slate-100 text-slate-700 font-semibold uppercase border-b border-slate-200">
<tr>
<th class="py-2.5 px-3 w-28">Mã Issue</th>
<th class="py-2.5 px-3">Tiêu Đề &amp; Chi Tiết Sai Lệch</th>
<th class="py-2.5 px-3 w-32">Phân Loại / Mức Độ</th>
<th class="py-2.5 px-3 w-44">CSDL &amp; Kỳ Xem</th>
<th class="py-2.5 px-3 w-32">Người Báo</th>
<th class="py-2.5 px-3 w-24 text-center">Tệp Đính Kèm</th>
<th class="py-2.5 px-3 w-32 text-center">Trạng Thái</th>
<th class="py-2.5 px-3 w-28 text-center">Thao Tác</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200" id="bodyIssuesList">
<tr><td colspan="8" class="text-center py-8 text-slate-400">Đang tải danh sách issue...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</main>
<!-- ======================================================== -->
<!-- MODAL 1: TẠO BÁO LỖI / ISSUE MỚI -->
<!-- ======================================================== -->
<div id="modalIssue" class="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[92vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<!-- Modal Header -->
<div class="px-6 py-4 bg-gradient-to-r from-rose-600 to-pink-600 text-white flex items-center justify-between">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center">
<i class="fa-solid fa-bug text-lg"></i>
</div>
<div>
<h3 class="text-base font-bold">Gửi Báo Lỗi / Sự Cố Hệ Thống</h3>
<p class="text-xs text-rose-100">Dữ liệu và file đính kèm sẽ được lưu lại để kỹ thuật tổng hợp xử lý</p>
</div>
</div>
<button id="btnCloseIssueModal" class="text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/10 text-lg transition-colors">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- Modal Form Body -->
<form id="formCreateIssue" class="p-6 overflow-y-auto space-y-4 flex-1 text-xs">
<!-- Tiêu đề -->
<div>
<label class="block font-bold text-slate-700 mb-1">
Tiêu đề vấn đề / lỗi <span class="text-rose-500">*</span>:
</label>
<input type="text" id="issueTitle" required placeholder="Ví dụ: Sai lệch số lượt khám BHYT Khoa Khám Bệnh so với HIS gốc" class="w-full text-sm rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 outline-none focus:ring-2 focus:ring-rose-500 font-medium">
</div>
<!-- Phân loại & Phân hệ & Mức độ -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block font-semibold text-slate-700 mb-1">Phân loại sự cố:</label>
<select id="issueCategory" class="w-full rounded-lg border border-slate-300 px-2.5 py-2 bg-slate-50 outline-none focus:ring-2 focus:ring-rose-500">
<option value="SAI_LECH_SO_LIEU">Sai lệch số liệu</option>
<option value="LOI_GIAO_DIEN">Lỗi giao diện / chức năng</option>
<option value="LOI_KET_NOI">Lỗi kết nối CSDL / timeout</option>
<option value="DE_XUAT_CAI_TIEN">Đề xuất cải tiến</option>
<option value="YEU_CAU_MOI">Yêu cầu báo cáo mới</option>
<option value="KHAC">Khác</option>
</select>
</div>
<div>
<label class="block font-semibold text-slate-700 mb-1">Phân hệ liên quan:</label>
<select id="issueModule" class="w-full rounded-lg border border-slate-300 px-2.5 py-2 bg-slate-50 outline-none focus:ring-2 focus:ring-rose-500">
<option value="NGOAI_TRU">1. Báo cáo Ngoại trú</option>
<option value="NOI_TRU">2. Báo cáo Nội trú</option>
<option value="CHUYEN_VIEN">Bảng 3. Chuyển viện</option>
<option value="THEO_DOI">Bảng 4. BN Theo dõi</option>
<option value="CHUNG">Chung / Toàn hệ thống</option>
</select>
</div>
<div>
<label class="block font-semibold text-slate-700 mb-1">Mức độ ưu tiên:</label>
<select id="issuePriority" class="w-full rounded-lg border border-slate-300 px-2.5 py-2 bg-slate-50 outline-none focus:ring-2 focus:ring-rose-500 font-bold text-amber-700">
<option value="BINH_THUONG" class="text-slate-700">Bình thường</option>
<option value="CAO" class="text-amber-700">Cao</option>
<option value="KHAN_CAP" class="text-rose-700">Khẩn cấp (Họp giao ban gấp)</option>
<option value="THAP" class="text-slate-500">Thấp</option>
</select>
</div>
</div>
<!-- CSDL & Mốc thời gian tự động -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 bg-slate-50 p-2.5 rounded-lg border border-slate-200">
<div>
<label class="block text-slate-500 mb-0.5">CSDL đang xem:</label>
<input type="text" id="issueDatabase" readonly class="w-full rounded border border-slate-300 px-2 py-1 bg-white text-slate-700 font-mono text-[11px]">
</div>
<div>
<label class="block text-slate-500 mb-0.5">Khoảng ngày đang xem:</label>
<input type="text" id="issuePeriod" readonly class="w-full rounded border border-slate-300 px-2 py-1 bg-white text-slate-700 font-mono text-[11px]">
</div>
</div>
<!-- Người báo & Khoa phòng -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-700 mb-1">Người báo cáo:</label>
<input type="text" id="issueReporter" placeholder="Ví dụ: BS. Nguyễn Văn A" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 outline-none focus:ring-2 focus:ring-rose-500 font-medium">
</div>
<div>
<label class="block font-semibold text-slate-700 mb-1">Khoa / Phòng ban:</label>
<input type="text" id="issueDepartment" placeholder="Ví dụ: Khoa Khám Bệnh / KHTH" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 outline-none focus:ring-2 focus:ring-rose-500 font-medium">
</div>
</div>
<!-- Nội dung mô tả chi tiết -->
<div>
<label class="block font-bold text-slate-700 mb-1">
Mô tả chi tiết sai lệch / lỗi <span class="text-rose-500">*</span>:
</label>
<textarea id="issueDescription" rows="4" required placeholder="- Số liệu kỳ vọng trên HIS gốc: ...&#10;- Số liệu hiển thị trên Web: ...&#10;- Thao tác thực hiện / lỗi xuất hiện:" class="w-full rounded-lg border border-slate-300 p-2.5 bg-slate-50 outline-none focus:ring-2 focus:ring-rose-500 text-xs leading-relaxed font-sans"></textarea>
</div>
<!-- Đính kèm file / Ảnh chụp màn hình -->
<div>
<label class="block font-bold text-slate-700 mb-1 flex items-center justify-between">
<span>Đính kèm tệp / Ảnh chụp màn hình:</span>
<span class="text-slate-400 font-normal">Hỗ trợ PNG, JPG, PDF, XLSX, Log (Tối đa 25MB)</span>
</label>
<div id="issuePasteZone" class="border-2 border-dashed border-slate-300 hover:border-rose-400 rounded-xl p-3 text-center bg-slate-50/50 hover:bg-rose-50/20 transition-all cursor-pointer">
<input type="file" id="issueFileInput" class="hidden" accept="image/*,.pdf,.xlsx,.xls,.doc,.docx,.txt,.csv">
<div id="pasteZonePrompt" class="flex flex-col items-center justify-center gap-1 text-slate-500">
<i class="fa-solid fa-cloud-arrow-up text-2xl text-rose-500 mb-1"></i>
<div class="text-xs font-semibold text-slate-700">Nhấp để chọn tệp hoặc <span class="text-rose-600 underline">dán ảnh chụp màn hình (Ctrl+V)</span></div>
<div class="text-[11px] text-slate-400">Có thể dán trực tiếp ảnh từ phím tắt PrintScreen / Snipping Tool</div>
</div>
<!-- File Preview Area -->
<div id="issuePreviewArea" class="hidden mt-2 flex items-center justify-between bg-white p-2.5 rounded-lg border border-slate-200">
<div class="flex items-center gap-2 overflow-hidden">
<img id="issueImgPreview" src="" class="hidden w-12 h-12 object-cover rounded border border-slate-200">
<i id="issueFileIcon" class="fa-solid fa-file text-2xl text-slate-400 hidden"></i>
<div class="text-left truncate">
<div id="issueFileName" class="font-bold text-slate-800 text-xs truncate">ten_file.png</div>
<div id="issueFileSize" class="text-[10px] text-slate-400">0 KB</div>
</div>
</div>
<button type="button" id="btnRemoveFile" class="text-rose-500 hover:text-rose-700 text-sm p-1.5 rounded hover:bg-rose-50">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="pt-2 flex items-center justify-end gap-2 border-t border-slate-200">
<button type="button" id="btnCancelIssue" class="px-4 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-100 rounded-lg transition-colors">
Hủy bỏ
</button>
<button type="submit" id="btnSubmitIssue" class="bg-gradient-to-r from-rose-600 to-pink-600 hover:from-rose-700 hover:to-pink-700 text-white px-5 py-2 rounded-lg font-bold shadow-md flex items-center gap-2 transition-all">
<i class="fa-solid fa-paper-plane"></i>
<span>Gửi Báo Lỗi &amp; Lưu File</span>
</button>
</div>
</form>
</div>
</div>
<!-- ======================================================== -->
<!-- MODAL 2: CẬP NHẬT TRẠNG THÁI & XỬ LÝ ISSUE -->
<!-- ======================================================== -->
<div id="modalUpdateIssue" class="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4 hidden">
<div class="bg-white rounded-2xl shadow-2xl max-w-lg w-full flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div class="px-5 py-3.5 bg-slate-800 text-white flex items-center justify-between">
<h3 class="text-sm font-bold flex items-center gap-2">
<i class="fa-solid fa-pen-nib text-amber-400"></i>
<span>Cập Nhật Xử Lý Issue: <span id="updateModalIssueId" class="font-mono text-cyan-300"></span></span>
</h3>
<button id="btnCloseUpdateModal" class="text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/10 text-base">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<form id="formUpdateIssue" class="p-5 space-y-3.5 text-xs">
<div>
<label class="block font-semibold text-slate-700 mb-1">Trạng thái xử lý:</label>
<select id="updateStatus" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 outline-none font-bold">
<option value="CHO_XU_LY" class="text-amber-700 font-bold">Chờ xử lý (Mới tiếp nhận)</option>
<option value="DANG_XU_LY" class="text-blue-700 font-bold">Đang xử lý (Đội ngũ kỹ thuật đang sửa)</option>
<option value="DA_XU_LY" class="text-emerald-700 font-bold">Đã xử lý (Đã fix lỗi / cập nhật số liệu)</option>
<option value="DONG" class="text-slate-500 font-bold">Đóng issue (Không phải lỗi / Đã xong)</option>
</select>
</div>
<div>
<label class="block font-semibold text-slate-700 mb-1">Cán bộ / Kỹ thuật xử lý:</label>
<input type="text" id="updateResolvedBy" placeholder="Ví dụ: Dev IT / Othau" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 outline-none font-medium">
</div>
<div>
<label class="block font-semibold text-slate-700 mb-1">Ghi chú giải pháp / Phản hồi xử lý:</label>
<textarea id="updateHandlerNotes" rows="3" placeholder="Ví dụ: Đã bổ sung điều kiện lọc theo mã đơn vị, số liệu đã khớp 100% với report HIS gốc." class="w-full rounded-lg border border-slate-300 p-2.5 bg-slate-50 outline-none leading-relaxed"></textarea>
</div>
<div class="pt-2 flex items-center justify-end gap-2 border-t border-slate-200">
<button type="button" id="btnCancelUpdate" class="px-4 py-2 font-semibold text-slate-600 hover:bg-slate-100 rounded-lg">
Đóng
</button>
<button type="submit" id="btnSaveUpdateIssue" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-bold shadow flex items-center gap-1.5">
<i class="fa-solid fa-floppy-disk"></i>
<span>Lưu Cập Nhật</span>
</button>
</div>
</form>
</div>
</div>
<!-- ======================================================== -->
<!-- MODAL 3: XEM ẢNH PHÓNG TO (LIGHTBOX) -->
<!-- ======================================================== -->
<div id="modalImagePreview" class="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-md flex items-center justify-center p-4 hidden">
<div class="relative max-w-4xl max-h-[90vh] flex flex-col items-center">
<button id="btnCloseLightbox" class="absolute -top-10 right-0 text-white/90 hover:text-white text-2xl p-1">
<i class="fa-solid fa-xmark"></i>
</button>
<img id="lightboxImg" src="" class="max-w-full max-h-[85vh] rounded-lg shadow-2xl object-contain border border-white/20">
<div id="lightboxCaption" class="mt-2 text-xs text-white/80 font-medium"></div>
</div>
</div>
<!-- NOTIFICATION TOAST -->
<div id="toast" class="fixed bottom-4 right-4 z-50 bg-slate-900 text-white text-xs px-4 py-2.5 rounded-lg shadow-xl flex items-center gap-2 transform translate-y-20 opacity-0 transition-all duration-300">
<i class="fa-solid fa-circle-check text-emerald-400"></i>
<span id="toastMsg">Thông báo</span>
</div>
<script src="app.js"></script>
</body>
</html>
+54
View File
@@ -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;
}
}
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"$DIR/stop.sh"
sleep 1
"$DIR/start.sh"
Executable
+28
View File
@@ -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
Executable
+14
View File
@@ -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
Executable
+18
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+13
View File
@@ -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
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+23
View File
@@ -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)
}
+71
View File
@@ -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")
})
}
+190
View File
@@ -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" {
owner = "ongtrieuhau"
}
repo := cfg.Updater.Repo
if repo == "" || repo == "DUAN" {
repo = "web-giao-ban"
}
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
}