Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f73ac7b5bb |
@@ -0,0 +1,6 @@
|
|||||||
|
PORT=3000
|
||||||
|
PG_HOST=192.168.50.79
|
||||||
|
PG_PORT=5437
|
||||||
|
PG_USER=postgres
|
||||||
|
PG_PASSWORD=123
|
||||||
|
PG_DATABASE=an_omon_full_04052026
|
||||||
+5
-6
@@ -1,6 +1,5 @@
|
|||||||
web-giao-ban
|
node_modules/
|
||||||
web-giao-ban.exe
|
*.log
|
||||||
*.syso
|
.app.pid
|
||||||
*.rc
|
uploads/issues/*
|
||||||
logs/
|
!uploads/issues/.gitkeep
|
||||||
uploads/
|
|
||||||
|
|||||||
@@ -1,115 +1,38 @@
|
|||||||
# DH-HIS Quản Lý Giao Ban Hằng Ngày (Golang Standalone)
|
# Phần Mềm Quản Lý Giao Ban Hằng Ngày (DH-HIS Web)
|
||||||
|
|
||||||
Ứ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.
|
Ứng dụng Node.js + Express + PostgreSQL phục vụ công tác giao ban bệnh viện hằng ngày, hỗ trợ phân tích đối chiếu đa chu kỳ và tương tác nhập liệu ghi chú trực tiếp.
|
||||||
|
|
||||||
---
|
## 1. Cấu trúc thư mục
|
||||||
|
```
|
||||||
|
dh-giaoban-web/
|
||||||
|
├── server.js # API Server Express (REST APIs cho Ngoại trú, Nội trú, Bảng 3 & 4)
|
||||||
|
├── db.js # Quản lý kết nối PostgreSQL Pool & chuẩn hóa văn bản RTF
|
||||||
|
├── .env # Cấu hình CSDL PostgreSQL DH37 (Host, Port, User, DB)
|
||||||
|
├── package.json # Dependencies: express, pg, cors, dotenv
|
||||||
|
└── public/
|
||||||
|
├── index.html # Giao diện Dashboard giao ban bệnh viện
|
||||||
|
├── app.js # Xử lý lọc kỳ, so sánh đa kỳ, vẽ biểu đồ Chart.js & auto-save ghi chú
|
||||||
|
└── styles.css # Định dạng giao diện & hỗ trợ in ấn (Print CSS)
|
||||||
|
```
|
||||||
|
|
||||||
## 🌟 1. Điểm nổi bật & Tính năng chính
|
## 2. Cách khởi động ứng dụng
|
||||||
|
|
||||||
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
|
```bash
|
||||||
./build.sh
|
cd /home/othau/dh-coding/dh-giaoban-web
|
||||||
|
node server.js
|
||||||
```
|
```
|
||||||
Kết quả sinh ra 2 file:
|
Mở trình duyệt truy cập: **http://localhost:3000** (hoặc `http://<IP_MÁY>:3000`).
|
||||||
- `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. Các tính năng chính
|
||||||
|
1. **Giao ban Ngoại trú**:
|
||||||
|
- Thống kê lượt khám BHYT, Thu phí, Chuyển viện theo các khoa (Khám bệnh, Ngoại, Nội, CSSKSS, Nhi, YDCT,...).
|
||||||
|
- Thống kê Bệnh án ngoại trú (Mới vs Tái khám) và chuyên khoa YDCT (Điện châm, VLTL, Sắc thuốc, Cấy chỉ).
|
||||||
|
- Tổng hợp số liệu Cận lâm sàng: Xét nghiệm (Huyết học, Sinh hóa, Nước tiểu, Miễn dịch, Đông máu) & CĐHA (Nội soi, Xquang, Siêu âm, CT, ECG).
|
||||||
|
- Top 10 chẩn đoán bệnh ngoại trú theo mã ICD-10.
|
||||||
|
- So sánh số liệu giữa 2 khoảng thời gian không liên tục (Ví dụ: Tháng 1 vs Tháng 3/10) và biểu diễn bằng biểu đồ cột.
|
||||||
|
|
||||||
## 💻 3. Hướng dẫn sử dụng trên Windows
|
2. **Giao ban Nội trú**:
|
||||||
|
- Tổng hợp lưu lượng bệnh nhân theo các khoa: Bệnh cũ (tồn đầu), Bệnh mới (nhập viện), Xuất viện (Ổn xin về, Nặng xin về, Trốn, Chuyển viện, Tử vong), Bệnh hiện còn, Bệnh theo dõi, Chăm sóc Cấp I.
|
||||||
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\`).
|
- Biểu đồ cột phân tích lưu lượng và so sánh đa chu kỳ.
|
||||||
2. Nhấp đúp vào `web-giao-ban.exe` để chạy.
|
- **Bảng 3 (Danh sách bệnh nhân Chuyển viện)** & **Bảng 4 (Danh sách bệnh nhân Theo dõi & CS Cấp I)**:
|
||||||
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.
|
- Hiển thị đầy đủ: Họ tên, Địa chỉ, Ngày VV/RV, Khoa, Chẩn đoán chính/phụ, Tóm tắt kết quả CLS.
|
||||||
4. Nhấp chuột phải vào icon khay hệ thống:
|
- **Cột Ghi chú thông minh**: Các khoa tự nhập liệu trực tiếp trên lưới; hệ thống tự động lưu vào CSDL ngay khi chuyển ô.
|
||||||
- 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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const pools = {};
|
||||||
|
|
||||||
|
function getPool(dbName) {
|
||||||
|
const database = dbName || process.env.PG_DATABASE || 'an_omon_full_04052026';
|
||||||
|
if (!pools[database]) {
|
||||||
|
pools[database] = new Pool({
|
||||||
|
host: process.env.PG_HOST || '192.168.50.79',
|
||||||
|
port: parseInt(process.env.PG_PORT || '5437', 10),
|
||||||
|
user: process.env.PG_USER || 'postgres',
|
||||||
|
password: process.env.PG_PASSWORD || '123',
|
||||||
|
database: database,
|
||||||
|
max: 10,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
statement_timeout: 15000, // 15s timeout to prevent hanging queries
|
||||||
|
});
|
||||||
|
|
||||||
|
pools[database].on('error', (err) => {
|
||||||
|
console.error(`Unexpected error on idle client for db ${database}`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pools[database];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function query(sql, params = [], dbName = null) {
|
||||||
|
const pool = getPool(dbName);
|
||||||
|
const start = Date.now();
|
||||||
|
const res = await pool.query(sql, params);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
// console.log(`[SQL ${duration}ms] executed on ${dbName || 'default'}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean RTF string helper
|
||||||
|
function cleanRtf(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
if (!text.includes('{\\rtf')) return text.trim();
|
||||||
|
// Strip RTF control codes
|
||||||
|
let clean = text
|
||||||
|
.replace(/\\par[d]?/g, '\n')
|
||||||
|
.replace(/\\u([0-9]{3,5})\?/g, (match, dec) => String.fromCharCode(dec))
|
||||||
|
.replace(/\\'([0-9a-fA-F]{2})/g, (match, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||||
|
.replace(/\{[^{}]*\}/g, '')
|
||||||
|
.replace(/\\[a-zA-Z0-9\-]+/g, '')
|
||||||
|
.replace(/[\{\}]/g, '')
|
||||||
|
.trim();
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getPool,
|
||||||
|
query,
|
||||||
|
cleanRtf,
|
||||||
|
};
|
||||||
@@ -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!",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const UPLOADS_DIR = path.join(__dirname, 'uploads', 'issues');
|
||||||
|
const ISSUES_FILE = path.join(DATA_DIR, 'issues.json');
|
||||||
|
|
||||||
|
function ensureDirs() {
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(UPLOADS_DIR)) {
|
||||||
|
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(ISSUES_FILE)) {
|
||||||
|
fs.writeFileSync(ISSUES_FILE, JSON.stringify([], null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllIssues() {
|
||||||
|
ensureDirs();
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(ISSUES_FILE, 'utf-8');
|
||||||
|
const list = JSON.parse(raw);
|
||||||
|
return Array.isArray(list) ? list : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reading issues.json:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveIssues(issues) {
|
||||||
|
ensureDirs();
|
||||||
|
const tmpFile = `${ISSUES_FILE}.tmp`;
|
||||||
|
fs.writeFileSync(tmpFile, JSON.stringify(issues, null, 2), 'utf-8');
|
||||||
|
fs.renameSync(tmpFile, ISSUES_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateIssueId() {
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
|
const prefix = `ISS-${y}${m}${d}`;
|
||||||
|
|
||||||
|
const all = getAllIssues();
|
||||||
|
const todayCount = all.filter(item => item.id && item.id.startsWith(prefix)).length + 1;
|
||||||
|
const seq = String(todayCount).padStart(3, '0');
|
||||||
|
return `${prefix}-${seq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIssue(data, file) {
|
||||||
|
ensureDirs();
|
||||||
|
const all = getAllIssues();
|
||||||
|
const id = generateIssueId();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
let attachment = null;
|
||||||
|
if (file) {
|
||||||
|
attachment = {
|
||||||
|
filename: file.filename,
|
||||||
|
originalname: file.originalname,
|
||||||
|
size: file.size,
|
||||||
|
mimetype: file.mimetype,
|
||||||
|
url: `/uploads/issues/${file.filename}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIssue = {
|
||||||
|
id,
|
||||||
|
title: (data.title || 'Không có tiêu đề').trim(),
|
||||||
|
category: data.category || 'SAI_LECH_SO_LIEU', // SAI_LECH_SO_LIEU, LOI_GIAO_DIEN, LOI_KET_NOI, DE_XUAT, YEU_CAU_MOI, KHAC
|
||||||
|
module: data.module || 'CHUNG', // NGOAI_TRU, NOI_TRU, CHUYEN_VIEN, THEO_DOI, CHUNG
|
||||||
|
priority: data.priority || 'BINH_THUONG', // THAP, BINH_THUONG, CAO, KHAN_CAP
|
||||||
|
database: data.database || '',
|
||||||
|
databaseName: data.databaseName || data.database || '',
|
||||||
|
period: data.period || '',
|
||||||
|
reporter: (data.reporter || 'Cán bộ giao ban').trim(),
|
||||||
|
department: (data.department || '').trim(),
|
||||||
|
description: (data.description || '').trim(),
|
||||||
|
status: 'CHO_XU_LY', // CHO_XU_LY, DANG_XU_LY, DA_XU_LY, DONG
|
||||||
|
handlerNotes: '',
|
||||||
|
resolvedBy: '',
|
||||||
|
resolvedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
attachment,
|
||||||
|
};
|
||||||
|
|
||||||
|
all.unshift(newIssue);
|
||||||
|
saveIssues(all);
|
||||||
|
return newIssue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIssue(id, updates) {
|
||||||
|
const all = getAllIssues();
|
||||||
|
const idx = all.findIndex(item => item.id === id);
|
||||||
|
if (idx === -1) {
|
||||||
|
throw new Error(`Không tìm thấy Issue với mã: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const cur = all[idx];
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...cur,
|
||||||
|
...updates,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (updates.status === 'DA_XU_LY' && cur.status !== 'DA_XU_LY') {
|
||||||
|
updated.resolvedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
all[idx] = updated;
|
||||||
|
saveIssues(all);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteIssue(id) {
|
||||||
|
const all = getAllIssues();
|
||||||
|
const idx = all.findIndex(item => item.id === id);
|
||||||
|
if (idx === -1) {
|
||||||
|
throw new Error(`Không tìm thấy Issue với mã: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = all[idx];
|
||||||
|
if (item.attachment && item.attachment.filename) {
|
||||||
|
const fPath = path.join(UPLOADS_DIR, item.attachment.filename);
|
||||||
|
if (fs.existsSync(fPath)) {
|
||||||
|
try { fs.unlinkSync(fPath); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all.splice(idx, 1);
|
||||||
|
saveIssues(all);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
UPLOADS_DIR,
|
||||||
|
ensureDirs,
|
||||||
|
getAllIssues,
|
||||||
|
createIssue,
|
||||||
|
updateIssue,
|
||||||
|
deleteIssue,
|
||||||
|
};
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+1048
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "dh-giaoban-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^18.0.9",
|
||||||
|
"express": "^5.3.0",
|
||||||
|
"multer": "^2.5.0",
|
||||||
|
"pg": "^8.23.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ''');
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// DH-HIS Quản Lý Giao Ban Web Client
|
// DH-HIS Quản Lý Giao Ban Web Client
|
||||||
const API_BASE = window.location.pathname.startsWith('/web-giao-ban-go') ? '/web-giao-ban-go' : (window.location.pathname.startsWith('/web-giao-ban') ? '/web-giao-ban' : '');
|
const API_BASE = window.location.pathname.startsWith('/web-giao-ban') ? '/web-giao-ban' : '';
|
||||||
let chartNgoaiTruInstance = null;
|
let chartNgoaiTruInstance = null;
|
||||||
let chartNoiTruInstance = null;
|
let chartNoiTruInstance = null;
|
||||||
let currentChartTypeNgoaiTru = 'khoa';
|
let currentChartTypeNgoaiTru = 'khoa';
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -34,7 +34,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- Database Selector -->
|
<!-- 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">
|
<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">
|
<label for="selectDb" class="text-sky-200 font-medium flex items-center gap-1 whitespace-nowrap">
|
||||||
@@ -48,13 +47,6 @@
|
|||||||
<option value="an_dalieu_full_05082026">BV Da Liễu (31 GB)</option>
|
<option value="an_dalieu_full_05082026">BV Da Liễu (31 GB)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
# Script khởi động lại dịch vụ web-giao-ban
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||||
"$DIR/stop.sh"
|
"$DIR/stop.sh"
|
||||||
sleep 1
|
sleep 1
|
||||||
"$DIR/start.sh"
|
"$DIR/start.sh"
|
||||||
|
|||||||
@@ -0,0 +1,533 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
const multer = require('multer');
|
||||||
|
const db = require('./db');
|
||||||
|
const issueService = require('./issueService');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
router.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||||
|
|
||||||
|
// Multer storage for issue attachments
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: function (req, file, cb) {
|
||||||
|
issueService.ensureDirs();
|
||||||
|
cb(null, issueService.UPLOADS_DIR);
|
||||||
|
},
|
||||||
|
filename: function (req, file, cb) {
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
const safeBase = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e6);
|
||||||
|
cb(null, `${safeBase}_${uniqueSuffix}${ext}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure giaoban_ghichu table exists
|
||||||
|
async function ensureTable(dbName) {
|
||||||
|
try {
|
||||||
|
await db.query(`
|
||||||
|
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_${dbName ? dbName.replace(/[^a-zA-Z0-9]/g, '_') : 'default'} UNIQUE(maba, loai_danhsach)
|
||||||
|
);
|
||||||
|
`, [], dbName);
|
||||||
|
} catch (err) {
|
||||||
|
// If permission or table exists, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Danh sách database
|
||||||
|
router.get('/api/databases', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const list = [
|
||||||
|
{ id: 'an_omon_full_04052026', name: 'BVĐK Ô Môn - Cần Thơ (33 GB - Đầy đủ khoa phòng chuẩn)' },
|
||||||
|
{ 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)' },
|
||||||
|
];
|
||||||
|
res.json({ success: true, data: list });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to build namkt and thangkt partition filter
|
||||||
|
function getThangNamFilter(fromDateStr, toDateStr, prefix = '') {
|
||||||
|
try {
|
||||||
|
const f = new Date(fromDateStr);
|
||||||
|
const t = new Date(toDateStr);
|
||||||
|
if (isNaN(f.getTime()) || isNaN(t.getTime())) return '';
|
||||||
|
|
||||||
|
const thangs = new Set();
|
||||||
|
const nams = new Set();
|
||||||
|
let curr = new Date(f.getFullYear(), f.getMonth(), 1);
|
||||||
|
const end = new Date(t.getFullYear(), t.getMonth(), 1);
|
||||||
|
let count = 0;
|
||||||
|
while (curr <= end && count < 24) {
|
||||||
|
thangs.add(String(curr.getMonth() + 1).padStart(2, '0'));
|
||||||
|
nams.add(String(curr.getFullYear()));
|
||||||
|
curr.setMonth(curr.getMonth() + 1);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
const p = prefix ? `${prefix}.` : '';
|
||||||
|
const thangsArr = Array.from(thangs).map(m => `'${m}'`).join(',');
|
||||||
|
const namsArr = Array.from(nams).map(y => `'${y}'`).join(',');
|
||||||
|
return ` AND ${p}namkt IN (${namsArr}) AND ${p}thangkt IN (${thangsArr}) `;
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for Outpatient query
|
||||||
|
async function getOutpatientData(database, fromDate, toDate) {
|
||||||
|
const params = [fromDate, toDate];
|
||||||
|
const fnKb = getThangNamFilter(fromDate, toDate, 'kb');
|
||||||
|
const fnLs = getThangNamFilter(fromDate, toDate, 'ls');
|
||||||
|
const fnNone = getThangNamFilter(fromDate, toDate, '');
|
||||||
|
|
||||||
|
// A. Thống kê theo Khoa khám bệnh
|
||||||
|
const sqlKhoa = `
|
||||||
|
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 ${fnKb}
|
||||||
|
AND COALESCE(kb.xoa, 0) = 0
|
||||||
|
AND COALESCE(kb.dakham, 0) != 0
|
||||||
|
GROUP BY dv.madv, dv.tendv
|
||||||
|
ORDER BY tong_kham DESC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// B. Bệnh án ngoại trú (Mới vs Tái khám)
|
||||||
|
const sqlBANT = `
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN COALESCE(dk.taikham, 0) = 0 THEN 1 ELSE 0 END) AS moi,
|
||||||
|
SUM(CASE WHEN COALESCE(dk.taikham, 0) = 1 THEN 1 ELSE 0 END) 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;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// C. Chuyên khoa YDCT
|
||||||
|
const sqlYDCT = `
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN cls.tencls ILIKE '%Điện châm%' THEN ls.soluong ELSE 0 END) AS dien_cham,
|
||||||
|
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) AS vltl,
|
||||||
|
SUM(CASE WHEN cls.tencls ILIKE '%Sắc thuốc%' THEN ls.soluong ELSE 0 END) AS sac_thuoc,
|
||||||
|
SUM(CASE WHEN cls.tencls ILIKE '%Cấy chỉ%' OR cls.tencls ILIKE '%Chôn chỉ%' THEN ls.soluong ELSE 0 END) AS cay_chi
|
||||||
|
FROM current.chidinhcls ls
|
||||||
|
JOIN current.dmcls cls ON ls.macls = cls.macls
|
||||||
|
WHERE ls.ngaykcb BETWEEN $1 AND $2 ${fnLs}
|
||||||
|
AND COALESCE(ls.xoa, 0) = 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// D. Cận lâm sàng
|
||||||
|
const sqlCLS = `
|
||||||
|
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 ${fnLs}
|
||||||
|
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;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// E. Top 10 chẩn đoán
|
||||||
|
const sqlTop10 = `
|
||||||
|
SELECT maicd, kqcdoan, COUNT(*) AS sl
|
||||||
|
FROM current.khambenh
|
||||||
|
WHERE ngaykcb BETWEEN $1 AND $2 ${fnNone}
|
||||||
|
AND COALESCE(xoa, 0) = 0
|
||||||
|
AND maicd IS NOT NULL AND maicd != ''
|
||||||
|
GROUP BY maicd, kqcdoan
|
||||||
|
ORDER BY sl DESC
|
||||||
|
LIMIT 10;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// F. Tiêm ngừa
|
||||||
|
const sqlTiemNgua = `
|
||||||
|
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 ${fnKb}
|
||||||
|
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%'
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [resKhoa, resBANT, resYDCT, resCLS, resTop10, resTiemNgua] = await Promise.all([
|
||||||
|
db.query(sqlKhoa, params, database),
|
||||||
|
db.query(sqlBANT, params, database),
|
||||||
|
db.query(sqlYDCT, params, database),
|
||||||
|
db.query(sqlCLS, params, database),
|
||||||
|
db.query(sqlTop10, params, database),
|
||||||
|
db.query(sqlTiemNgua, params, database),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
khoa: resKhoa.rows,
|
||||||
|
bant: resBANT.rows[0] || { moi: 0, taikham: 0, tong: 0 },
|
||||||
|
ydct: resYDCT.rows[0] || { dien_cham: 0, vltl: 0, sac_thuoc: 0, cay_chi: 0 },
|
||||||
|
cls: resCLS.rows,
|
||||||
|
top10: resTop10.rows,
|
||||||
|
tiemngua: parseInt(resTiemNgua.rows[0]?.sl || 0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. API Báo cáo Giao ban Ngoại trú
|
||||||
|
router.get('/api/giao-ban-ngoai-tru', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { database, from, to, compareFrom, compareTo } = req.query;
|
||||||
|
const dbName = database || process.env.PG_DATABASE;
|
||||||
|
const fromDate = from || '2026-03-01 00:00:00';
|
||||||
|
const toDate = to || '2026-03-31 23:59:59';
|
||||||
|
|
||||||
|
const currentData = await getOutpatientData(dbName, fromDate, toDate);
|
||||||
|
|
||||||
|
let compareData = null;
|
||||||
|
if (compareFrom && compareTo) {
|
||||||
|
compareData = await getOutpatientData(dbName, compareFrom, compareTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
current: currentData,
|
||||||
|
compare: compareData,
|
||||||
|
periodCurrent: { from: fromDate, to: toDate },
|
||||||
|
periodCompare: compareFrom && compareTo ? { from: compareFrom, to: compareTo } : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in /api/giao-ban-ngoai-tru:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper for Inpatient query
|
||||||
|
async function getInpatientData(database, fromDate, toDate) {
|
||||||
|
const params = [fromDate, toDate];
|
||||||
|
|
||||||
|
const sqlKhoa = `
|
||||||
|
SELECT
|
||||||
|
dv.madv,
|
||||||
|
dv.tendv,
|
||||||
|
-- 1. Bệnh cũ (tồn đầu kỳ)
|
||||||
|
SUM(CASE WHEN nt.ngayvv < $1 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) AS benh_cu,
|
||||||
|
-- 2. Bệnh mới (vào viện trong kỳ)
|
||||||
|
SUM(CASE WHEN nt.ngayvv BETWEEN $1 AND $2 THEN 1 ELSE 0 END) AS benh_moi,
|
||||||
|
-- 3. Xuất viện:
|
||||||
|
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,
|
||||||
|
-- 4. Bệnh hiện còn (tồn cuối kỳ)
|
||||||
|
SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv > $2) THEN 1 ELSE 0 END) AS benh_hien_con,
|
||||||
|
-- 5. Bệnh theo dõi nặng
|
||||||
|
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,
|
||||||
|
-- 6. Chăm sóc Cấp I
|
||||||
|
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;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resKhoa = await db.query(sqlKhoa, params, database);
|
||||||
|
return {
|
||||||
|
khoa: resKhoa.rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. API Báo cáo Giao ban Nội trú
|
||||||
|
router.get('/api/giao-ban-noi-tru', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { database, from, to, compareFrom, compareTo } = req.query;
|
||||||
|
const dbName = database || process.env.PG_DATABASE;
|
||||||
|
const fromDate = from || '2026-03-01 00:00:00';
|
||||||
|
const toDate = to || '2026-03-31 23:59:59';
|
||||||
|
|
||||||
|
await ensureTable(dbName);
|
||||||
|
const currentData = await getInpatientData(dbName, fromDate, toDate);
|
||||||
|
|
||||||
|
let compareData = null;
|
||||||
|
if (compareFrom && compareTo) {
|
||||||
|
compareData = await getInpatientData(dbName, compareFrom, compareTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
current: currentData,
|
||||||
|
compare: compareData,
|
||||||
|
periodCurrent: { from: fromDate, to: toDate },
|
||||||
|
periodCompare: compareFrom && compareTo ? { from: compareFrom, to: compareTo } : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in /api/giao-ban-noi-tru:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. API Bảng 3: Danh sách Bệnh nhân Chuyển viện
|
||||||
|
router.get('/api/danh-sach-chuyen-vien', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { database, from, to, madv } = req.query;
|
||||||
|
const dbName = database || process.env.PG_DATABASE;
|
||||||
|
const fromDate = from || '2026-03-01 00:00:00';
|
||||||
|
const toDate = to || '2026-03-31 23:59:59';
|
||||||
|
|
||||||
|
await ensureTable(dbName);
|
||||||
|
|
||||||
|
let 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')
|
||||||
|
`;
|
||||||
|
const params = [fromDate, toDate];
|
||||||
|
if (madv && madv !== 'ALL') {
|
||||||
|
params.push(madv);
|
||||||
|
sqlWhere += ` AND nt.madv = $${params.length} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
nt.mabn,
|
||||||
|
nt.maba,
|
||||||
|
bn.holot || ' ' || bn.ten AS hoten,
|
||||||
|
bn.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,
|
||||||
|
nt.kqcdoan AS chandoan_chinh,
|
||||||
|
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
|
||||||
|
${sqlWhere}
|
||||||
|
ORDER BY nt.ngayrv DESC
|
||||||
|
LIMIT 100;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(sql, params, dbName);
|
||||||
|
const cleanedRows = result.rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
ketqua_cls: db.cleanRtf(row.ketqua_cls)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ success: true, data: cleanedRows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in /api/danh-sach-chuyen-vien:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. API Bảng 4: Danh sách Bệnh nhân Theo dõi & Chăm sóc Cấp I
|
||||||
|
router.get('/api/danh-sach-theo-doi', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { database, from, to, madv } = req.query;
|
||||||
|
const dbName = database || process.env.PG_DATABASE;
|
||||||
|
const fromDate = from || '2026-03-01 00:00:00';
|
||||||
|
const toDate = to || '2026-03-31 23:59:59';
|
||||||
|
|
||||||
|
await ensureTable(dbName);
|
||||||
|
|
||||||
|
let 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')
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const params = [fromDate, toDate];
|
||||||
|
if (madv && madv !== 'ALL') {
|
||||||
|
params.push(madv);
|
||||||
|
sqlWhere += ` AND nt.madv = $${params.length} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
nt.mabn,
|
||||||
|
nt.maba,
|
||||||
|
bn.holot || ' ' || bn.ten AS hoten,
|
||||||
|
bn.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,
|
||||||
|
nt.kqcdoan AS chandoan_chinh,
|
||||||
|
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
|
||||||
|
${sqlWhere}
|
||||||
|
ORDER BY nt.ngayvv DESC
|
||||||
|
LIMIT 100;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(sql, params, dbName);
|
||||||
|
const cleanedRows = result.rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
ketqua_cls: db.cleanRtf(row.ketqua_cls)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ success: true, data: cleanedRows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in /api/danh-sach-theo-doi:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. API Cập nhật Ghi chú trực tiếp từ lưới (Bảng 3 & Bảng 4)
|
||||||
|
router.post('/api/cap-nhat-ghi-chu', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { maba, mabn, loai, ghichu, database } = req.body;
|
||||||
|
if (!maba || !loai) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Thiếu maba hoặc loại danh sách' });
|
||||||
|
}
|
||||||
|
const dbName = database || process.env.PG_DATABASE;
|
||||||
|
await ensureTable(dbName);
|
||||||
|
|
||||||
|
const 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();
|
||||||
|
`;
|
||||||
|
await db.query(sqlUpsert, [maba, mabn || '', loai, ghichu || ''], dbName);
|
||||||
|
|
||||||
|
// Đồng bộ vào bnnoitru.ghichu nếu cần
|
||||||
|
try {
|
||||||
|
await db.query(`UPDATE current.bnnoitru SET ghichu = $1 WHERE maba = $2;`, [ghichu || '', maba], dbName);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Đã lưu ghi chú thành công!' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in /api/cap-nhat-ghi-chu:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. API Quản lý Issue & Báo lỗi (lưu file và tổng hợp)
|
||||||
|
router.get('/api/issues', (req, res) => {
|
||||||
|
try {
|
||||||
|
const list = issueService.getAllIssues();
|
||||||
|
res.json({ success: true, data: list });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/issues', upload.single('attachment'), (req, res) => {
|
||||||
|
try {
|
||||||
|
const issue = issueService.createIssue(req.body, req.file);
|
||||||
|
res.json({ success: true, data: issue, message: 'Đã ghi nhận báo lỗi thành công!' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating issue:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/api/issues/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const updated = issueService.updateIssue(req.params.id, req.body);
|
||||||
|
res.json({ success: true, data: updated, message: 'Cập nhật trạng thái issue thành công!' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating issue:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/api/issues/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
issueService.deleteIssue(req.params.id);
|
||||||
|
res.json({ success: true, message: 'Đã xóa issue thành công!' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting issue:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mount router on both /web-giao-ban and /
|
||||||
|
app.use('/web-giao-ban', router);
|
||||||
|
app.use('/', router);
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`=======================================================`);
|
||||||
|
console.log(`[DH-HIS] Phần mềm Quản lý Giao ban hằng ngày (Node.js)`);
|
||||||
|
console.log(`Server is running at: http://localhost:${PORT}`);
|
||||||
|
console.log(`=======================================================`);
|
||||||
|
});
|
||||||
@@ -1,28 +1,21 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
# Script khởi động dịch vụ web-giao-ban
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||||
cd "$DIR"
|
cd "$DIR"
|
||||||
|
|
||||||
# Check if systemd service exists and active
|
if systemctl is-enabled web-giao-ban.service >/dev/null 2>&1; then
|
||||||
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 "Starting web-giao-ban via systemd..."
|
||||||
echo "Khởi động qua systemd service: web-giao-ban-go..."
|
sudo systemctl start web-giao-ban
|
||||||
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
|
sleep 1
|
||||||
PID=$(pgrep -f "$DIR/web-giao-ban" || true)
|
sudo systemctl status web-giao-ban --no-pager
|
||||||
if [ -n "$PID" ]; then
|
|
||||||
echo "Khởi động thành công! PID: $PID"
|
|
||||||
else
|
else
|
||||||
echo "Khởi động thất bại, kiểm tra logs/app.log"
|
PID_FILE="$DIR/.app.pid"
|
||||||
|
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||||
|
echo "web-giao-ban is already running (PID: $(cat "$PID_FILE"))"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Starting web-giao-ban in background..."
|
||||||
|
nohup /usr/bin/node server.js > app.log 2> error.log &
|
||||||
|
echo $! > "$PID_FILE"
|
||||||
|
echo "Started with PID: $!"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
# Script kiểm tra trạng thái web-giao-ban
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
if systemctl list-unit-files | grep -q "web-giao-ban-go.service"; then
|
echo "=== TRẠNG THÁI DỊCH VỤ WEB-GIAO-BAN ==="
|
||||||
sudo systemctl status web-giao-ban-go --no-pager
|
if systemctl is-active web-giao-ban.service >/dev/null 2>&1; then
|
||||||
exit 0
|
sudo systemctl status web-giao-ban --no-pager
|
||||||
fi
|
|
||||||
|
|
||||||
PID=$(pgrep -f "$DIR/web-giao-ban" || true)
|
|
||||||
if [ -n "$PID" ]; then
|
|
||||||
echo "● web-giao-ban (Go) is RUNNING (PID: $PID)"
|
|
||||||
else
|
else
|
||||||
echo "○ web-giao-ban (Go) is STOPPED"
|
PID_FILE="$DIR/.app.pid"
|
||||||
|
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||||
|
echo "Status: RUNNING (PID: $(cat "$PID_FILE"))"
|
||||||
|
else
|
||||||
|
echo "Status: STOPPED"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== KIỂM TRA KẾT NỐI PORT 3000 ==="
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/web-giao-ban/ || echo "ERR")
|
||||||
|
echo "HTTP Status: $HTTP_CODE (http://127.0.0.1:3000/web-giao-ban/)"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" == "200" ]; then
|
||||||
|
echo "Dịch vụ web-giao-ban đang hoạt động tốt!"
|
||||||
|
else
|
||||||
|
echo "Cảnh báo: Không thể truy cập cổng 3000 hoặc mã trả về không phải 200."
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
# Script dừng dịch vụ web-giao-ban
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||||
|
cd "$DIR"
|
||||||
|
|
||||||
if systemctl is-active --quiet web-giao-ban-go; then
|
if systemctl is-active web-giao-ban.service >/dev/null 2>&1; then
|
||||||
echo "Dừng systemd service web-giao-ban-go..."
|
echo "Stopping web-giao-ban via systemd..."
|
||||||
sudo systemctl stop web-giao-ban-go
|
sudo systemctl stop web-giao-ban
|
||||||
exit 0
|
echo "Stopped."
|
||||||
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
|
else
|
||||||
echo "Ứng dụng không chạy."
|
PID_FILE="$DIR/.app.pid"
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
PID="$(cat "$PID_FILE")"
|
||||||
|
if kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo "Stopping web-giao-ban (PID: $PID)..."
|
||||||
|
kill "$PID" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
echo "Stopped."
|
||||||
|
else
|
||||||
|
# Kill by port 3000 if running
|
||||||
|
PID=$(lsof -t -i:3000 2>/dev/null || fuser 3000/tcp 2>/dev/null)
|
||||||
|
if [ -n "$PID" ]; then
|
||||||
|
echo "Killing process on port 3000 (PID: $PID)..."
|
||||||
|
kill -9 $PID 2>/dev/null || true
|
||||||
|
echo "Stopped."
|
||||||
|
else
|
||||||
|
echo "web-giao-ban is not running."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
fi
|
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