Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d54ca20f6 | ||
|
|
f5f118c5b0 | ||
|
|
228d4d14df |
@@ -0,0 +1,6 @@
|
|||||||
|
web-giao-ban
|
||||||
|
web-giao-ban.exe
|
||||||
|
*.syso
|
||||||
|
*.rc
|
||||||
|
logs/
|
||||||
|
uploads/
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
@@ -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!",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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!",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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...))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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ị & 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 & 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 & 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 & 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 & 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 & 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 & 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
@@ -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 & 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 & Cập Nhật Tự Động</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
+1544
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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ú & 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ị & 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 & 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 & 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 & 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 & 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 & 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 Đề & 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 & 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: ... - Số liệu hiển thị trên Web: ... - 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 & 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>
|
||||||
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
"$DIR/stop.sh"
|
||||||
|
sleep 1
|
||||||
|
"$DIR/start.sh"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
|
# Check if systemd service exists and active
|
||||||
|
if systemctl is-enabled --quiet web-giao-ban-go 2>/dev/null || systemctl is-active --quiet web-giao-ban-go 2>/dev/null; then
|
||||||
|
echo "Khởi động qua systemd service: web-giao-ban-go..."
|
||||||
|
sudo systemctl start web-giao-ban-go
|
||||||
|
sudo systemctl status web-giao-ban-go --no-pager
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback to direct process
|
||||||
|
PID=$(pgrep -f "$DIR/web-giao-ban" || true)
|
||||||
|
if [ -n "$PID" ]; then
|
||||||
|
echo "Ứng dụng Go đang chạy với PID: $PID"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Đang khởi động web-giao-ban nền..."
|
||||||
|
nohup ./web-giao-ban > /dev/null 2>&1 &
|
||||||
|
sleep 1
|
||||||
|
PID=$(pgrep -f "$DIR/web-giao-ban" || true)
|
||||||
|
if [ -n "$PID" ]; then
|
||||||
|
echo "Khởi động thành công! PID: $PID"
|
||||||
|
else
|
||||||
|
echo "Khởi động thất bại, kiểm tra logs/app.log"
|
||||||
|
fi
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -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
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user