Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbe96e0eea |
@@ -1,6 +0,0 @@
|
|||||||
web-giao-ban
|
|
||||||
web-giao-ban.exe
|
|
||||||
*.syso
|
|
||||||
*.rc
|
|
||||||
logs/
|
|
||||||
uploads/
|
|
||||||
@@ -1,115 +1,3 @@
|
|||||||
# DH-HIS Quản Lý Giao Ban Hằng Ngày (Golang Standalone)
|
# web-giao-ban
|
||||||
|
|
||||||
Ứ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.
|
Phần mềm Quản lý Giao ban hằng ngày (Golang Standalone Single Binary)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌟 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)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
#!/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
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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=
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
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
@@ -1,681 +0,0 @@
|
|||||||
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!",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
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!",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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...))
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
<!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
@@ -1,448 +0,0 @@
|
|||||||
// 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.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,907 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
"$DIR/stop.sh"
|
|
||||||
sleep 1
|
|
||||||
"$DIR/start.sh"
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/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.
|
Before Width: | Height: | Size: 30 KiB |
@@ -1,13 +0,0 @@
|
|||||||
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.
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,23 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
//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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
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