feat: Tích hợp Bản quyền License AES-256 (OTH.Adapter), Biên bản giao ban A4, Biểu đồ tròn và Tài liệu triển khai demo
This commit is contained in:
+5
-1
@@ -39,6 +39,10 @@
|
||||
"owner": "ongtrieuhau",
|
||||
"repo": "web-giao-ban",
|
||||
"token": "ENC(abS+Aa/rBa8ydyLgFvGmDrJ5Vc9KOVTFu52PX4rCcA+88edzq5J6yWu05DPTTHWZEw2wKz+8RmXbt+jTTexJi+zATOg=)",
|
||||
"current_version": "v1.0.1"
|
||||
"current_version": "v1.0.2"
|
||||
},
|
||||
"license": {
|
||||
"crypto_key": "ENC(R8h55AZzKMVH6QO+X/mBHvoMBLSBOZ9HCXSH2CbLrEwCUgGzFf7aJ5p8VCv7DZAMY6TkJQemYZIcXRS0)",
|
||||
"key": "U2FsdGVkX18Zsq52FPbB7FS1QM7CwWh+t1Esi96gUMFILJD0vZXM69uQBC4sP7TjLVOiJPZt0KIq17iql+hXehLCVSbCEhwKKm+zNab+u2Swah63A52hz0KMJXddLRZ6vanmP2goLTzY7749HsFVRDq+3bdbi8fdUZCpAsSTEiqC2cRTpF5WVh0SHt1wX/i42YSQgOYPeXQtg+lRx/URk4p7lYSVQOYWXglp99kO+Va1ADoNcTgrfz3aaAxAkj0S"
|
||||
}
|
||||
}
|
||||
@@ -46,15 +46,22 @@ type UpdaterConfig struct {
|
||||
CurrentVersion string `json:"current_version"`
|
||||
}
|
||||
|
||||
type LicenseConfig struct {
|
||||
CryptoKey string `json:"crypto_key"` // stored as ENC(...)
|
||||
Key string `json:"key"` // stored ciphertext U2FsdGVk...
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Server ServerConfig `json:"server"`
|
||||
Database DBConfig `json:"database"`
|
||||
Updater UpdaterConfig `json:"updater"`
|
||||
License LicenseConfig `json:"license"`
|
||||
|
||||
// runtime plaintext fields (not exported to JSON if needed)
|
||||
DecryptedDBPassword string `json:"-"`
|
||||
DecryptedUpdaterToken string `json:"-"`
|
||||
DecryptedAdminPassword string `json:"-"`
|
||||
DecryptedCryptoKey string `json:"-"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -154,6 +161,10 @@ func DefaultConfig() *AppConfig {
|
||||
Token: "",
|
||||
CurrentVersion: "v1.0.2",
|
||||
},
|
||||
License: LicenseConfig{
|
||||
CryptoKey: "BAD5A05F330D5B8CDDFCA9E7ECDC840B",
|
||||
Key: "",
|
||||
},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -171,15 +182,18 @@ func LoadConfig(path string) (*AppConfig, error) {
|
||||
cfg.DecryptedAdminPassword = cfg.Server.AdminPassword
|
||||
cfg.DecryptedDBPassword = cfg.Database.Password
|
||||
cfg.DecryptedUpdaterToken = cfg.Updater.Token
|
||||
cfg.DecryptedCryptoKey = cfg.License.CryptoKey
|
||||
|
||||
// Encrypt for disk save
|
||||
encAdmin, _ := Encrypt(cfg.Server.AdminPassword)
|
||||
encDB, _ := Encrypt(cfg.Database.Password)
|
||||
encTok, _ := Encrypt(cfg.Updater.Token)
|
||||
encCrypto, _ := Encrypt(cfg.License.CryptoKey)
|
||||
|
||||
cfg.Server.AdminPassword = encAdmin
|
||||
cfg.Database.Password = encDB
|
||||
cfg.Updater.Token = encTok
|
||||
cfg.License.CryptoKey = encCrypto
|
||||
|
||||
data, _ := json.MarshalIndent(cfg, "", " ")
|
||||
_ = os.MkdirAll(filepath.Dir(configPath), 0755)
|
||||
@@ -203,10 +217,24 @@ func LoadConfig(path string) (*AppConfig, error) {
|
||||
decAdmin, _ := Decrypt(cfg.Server.AdminPassword)
|
||||
decDB, _ := Decrypt(cfg.Database.Password)
|
||||
decTok, _ := Decrypt(cfg.Updater.Token)
|
||||
if cfg.License.CryptoKey == "" {
|
||||
cfg.License.CryptoKey = "BAD5A05F330D5B8CDDFCA9E7ECDC840B"
|
||||
}
|
||||
decCrypto, _ := Decrypt(cfg.License.CryptoKey)
|
||||
if decCrypto == "" {
|
||||
decCrypto = "BAD5A05F330D5B8CDDFCA9E7ECDC840B"
|
||||
}
|
||||
|
||||
cfg.DecryptedAdminPassword = decAdmin
|
||||
cfg.DecryptedDBPassword = decDB
|
||||
cfg.DecryptedUpdaterToken = decTok
|
||||
cfg.DecryptedCryptoKey = decCrypto
|
||||
|
||||
// Ensure crypto key is stored encrypted on disk
|
||||
if !strings.HasPrefix(cfg.License.CryptoKey, "ENC(") {
|
||||
encCrypto, _ := Encrypt(cfg.License.CryptoKey)
|
||||
cfg.License.CryptoKey = encCrypto
|
||||
}
|
||||
|
||||
// Auto-migrate legacy owner & repo
|
||||
if cfg.Updater.Owner == "" || cfg.Updater.Owner == "hdhiswork" {
|
||||
@@ -242,6 +270,14 @@ func SaveConfig(cfg *AppConfig) error {
|
||||
enc, _ := Encrypt(clone.Updater.Token)
|
||||
clone.Updater.Token = enc
|
||||
}
|
||||
if clone.License.CryptoKey == "" {
|
||||
clone.License.CryptoKey = "BAD5A05F330D5B8CDDFCA9E7ECDC840B"
|
||||
}
|
||||
if !strings.HasPrefix(clone.License.CryptoKey, "ENC(") {
|
||||
clone.DecryptedCryptoKey = clone.License.CryptoKey
|
||||
enc, _ := Encrypt(clone.License.CryptoKey)
|
||||
clone.License.CryptoKey = enc
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(&clone, "", " ")
|
||||
if err != nil {
|
||||
|
||||
@@ -1,4 +1,148 @@
|
||||
[
|
||||
{
|
||||
"id": "ISS-20261027-004",
|
||||
"title": "So sánh theo kỳ",
|
||||
"category": "DE_XUAT_CAI_TIEN",
|
||||
"module": "CHUNG",
|
||||
"priority": "BINH_THUONG",
|
||||
"database": "an_omon_full_04052026",
|
||||
"databaseName": "BVĐK Ô Môn - Cần Thơ (33 GB - Đầy đủ khoa phòng chuẩn)",
|
||||
"period": "Từ 2026-02-01 đến 2026-02-28",
|
||||
"reporter": "Cán bộ giao ban",
|
||||
"department": "",
|
||||
"description": "- Thể hiện hết cá tiêu chi so sánh, hiện chỉ so sánh được tổng lượt bệnh ngoại trú (hình).\r\n- Ca tiêu chí không thực hiện so sánh không hiểu thị khi xem kết quả so sánh (cho đở rối mắt)",
|
||||
"status": "CHO_XU_LY",
|
||||
"handlerNotes": "",
|
||||
"resolvedBy": "",
|
||||
"resolvedAt": null,
|
||||
"createdAt": "2026-10-27T07:27:43.902Z",
|
||||
"updatedAt": "2026-10-27T07:27:43.902Z",
|
||||
"attachment": {
|
||||
"filename": "4_1793086063140-377528.png",
|
||||
"originalname": "4.png",
|
||||
"size": 75382,
|
||||
"mimetype": "image/png",
|
||||
"url": "/uploads/issues/4_1793086063140-377528.png"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ISS-20261027-003",
|
||||
"title": "Hỗ trợ chọn mốc thời gian khi thực hiện so sánh 2 kỳ tương tự như khi chọn báo cáo.",
|
||||
"category": "DE_XUAT_CAI_TIEN",
|
||||
"module": "CHUNG",
|
||||
"priority": "BINH_THUONG",
|
||||
"database": "an_omon_full_04052026",
|
||||
"databaseName": "BVĐK Ô Môn - Cần Thơ (33 GB - Đầy đủ khoa phòng chuẩn)",
|
||||
"period": "Từ 2026-02-01 đến 2026-02-28",
|
||||
"reporter": "Cán bộ giao ban",
|
||||
"department": "",
|
||||
"description": "- Khi chọn so sánh 2 kỳ, hỗ trợ kỳ so sánh chọn nhanh mốc thời gian tượng tự khi chọn xem báo cáo.",
|
||||
"status": "CHO_XU_LY",
|
||||
"handlerNotes": "",
|
||||
"resolvedBy": "",
|
||||
"resolvedAt": null,
|
||||
"createdAt": "2026-10-27T07:16:37.213Z",
|
||||
"updatedAt": "2026-10-27T07:16:37.213Z",
|
||||
"attachment": {
|
||||
"filename": "3_1793085396482-710784.png",
|
||||
"originalname": "3.png",
|
||||
"size": 22222,
|
||||
"mimetype": "image/png",
|
||||
"url": "/uploads/issues/3_1793085396482-710784.png"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ISS-20261027-002",
|
||||
"title": "Thêm tab hỗ trợ người dùng ghi biên bản giao ban.",
|
||||
"category": "DE_XUAT_CAI_TIEN",
|
||||
"module": "CHUNG",
|
||||
"priority": "BINH_THUONG",
|
||||
"database": "an_omon_full_04052026",
|
||||
"databaseName": "BVĐK Ô Môn - Cần Thơ (33 GB - Đầy đủ khoa phòng chuẩn)",
|
||||
"period": "Từ 2026-03-31 đến 2026-03-31",
|
||||
"reporter": "Cán bộ giao ban",
|
||||
"department": "",
|
||||
"description": "- Bổ sung thêm phần ghi biên bản giao ban: mẫu hỗ trợ tự thiết kế.\r\n- Phần quyền tài khoản theo 3 cấp: được xem , được chỉnh sửa, được kết luận.\r\n- In biên bản giao ban với các nội dung đã được ghi nhận theo mẫu đã thiết kế.",
|
||||
"status": "CHO_XU_LY",
|
||||
"handlerNotes": "",
|
||||
"resolvedBy": "",
|
||||
"resolvedAt": null,
|
||||
"createdAt": "2026-10-27T07:10:09.965Z",
|
||||
"updatedAt": "2026-10-27T07:10:09.965Z",
|
||||
"attachment": null
|
||||
},
|
||||
{
|
||||
"id": "ISS-20261027-001",
|
||||
"title": "Bổ sung option tùy chọn xem dạng biểu đồ các tiêu chi",
|
||||
"category": "DE_XUAT_CAI_TIEN",
|
||||
"module": "NOI_TRU",
|
||||
"priority": "BINH_THUONG",
|
||||
"database": "an_omon_full_04052026",
|
||||
"databaseName": "BVĐK Ô Môn - Cần Thơ (33 GB - Đầy đủ khoa phòng chuẩn)",
|
||||
"period": "Từ 2026-03-31 đến 2026-03-31",
|
||||
"reporter": "Cán bộ giao ban",
|
||||
"department": "",
|
||||
"description": "- Option cho người dùng tùy chọn xem dạng biểu đồ, bổ sung biểu hình tròn cho từng tiêu chí: Bệnh cũ, bệnh mới, xuất viện...",
|
||||
"status": "CHO_XU_LY",
|
||||
"handlerNotes": "",
|
||||
"resolvedBy": "",
|
||||
"resolvedAt": null,
|
||||
"createdAt": "2026-10-27T07:05:23.376Z",
|
||||
"updatedAt": "2026-10-27T07:05:23.376Z",
|
||||
"attachment": null
|
||||
},
|
||||
{
|
||||
"id": "ISS-20261026-002",
|
||||
"title": "Yêu cầu - Bổ sung chức năng chuyển đổi sang dạng biểu độ cho người dung tùy chọn",
|
||||
"category": "DE_XUAT_CAI_TIEN",
|
||||
"module": "NGOAI_TRU",
|
||||
"priority": "BINH_THUONG",
|
||||
"database": "an_omon_full_04052026",
|
||||
"databaseName": "BVĐK Ô Môn - Cần Thơ (33 GB - Đầy đủ khoa phòng chuẩn)",
|
||||
"period": "Từ 2026-03-31 đến 2026-03-31",
|
||||
"reporter": "Cán bộ giao ban",
|
||||
"department": "",
|
||||
"description": "- Biều đồ số lượt khám từng khoa chiếm bao nhiêu % trên tổng viện (biếu đồ hình tròn).\r\n- Trong từng khoa thì tỉ lệ khám BHYT và Thu phí (biểu đồ hình cột, mỗi cột thể hiện tỉ lệ cho từng khoa)",
|
||||
"status": "CHO_XU_LY",
|
||||
"handlerNotes": "",
|
||||
"resolvedBy": "",
|
||||
"resolvedAt": null,
|
||||
"createdAt": "2026-10-27T04:51:57.805Z",
|
||||
"updatedAt": "2026-10-27T04:51:57.805Z",
|
||||
"attachment": {
|
||||
"filename": "2_1793076717292-639462.png",
|
||||
"originalname": "2.png",
|
||||
"size": 69383,
|
||||
"mimetype": "image/png",
|
||||
"url": "/uploads/issues/2_1793076717292-639462.png"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ISS-20261026-001",
|
||||
"title": "Khóa chức năng chọn ngày khi người dùng đã chọn lại mốc báo cáo.",
|
||||
"category": "SAI_LECH_SO_LIEU",
|
||||
"module": "NGOAI_TRU",
|
||||
"priority": "BINH_THUONG",
|
||||
"database": "an_omon_full_04052026",
|
||||
"databaseName": "BVĐK Ô Môn - Cần Thơ (33 GB - Đầy đủ khoa phòng chuẩn)",
|
||||
"period": "Từ 2026-04-08 đến 2026-04-30",
|
||||
"reporter": "Cán bộ giao ban",
|
||||
"department": "",
|
||||
"description": "- Chọn báo cáo Tháng 04/2026 nhưng vẫn cho chọn ngày bắt đầu ngày kết thúc.\r\n- Yêu cầu: khóa không cho chọn ngày lại.",
|
||||
"status": "CHO_XU_LY",
|
||||
"handlerNotes": "",
|
||||
"resolvedBy": "",
|
||||
"resolvedAt": null,
|
||||
"createdAt": "2026-10-27T04:30:47.211Z",
|
||||
"updatedAt": "2026-10-27T04:30:47.211Z",
|
||||
"attachment": {
|
||||
"filename": "1_1793075446720-365231.png",
|
||||
"originalname": "1.png",
|
||||
"size": 67253,
|
||||
"mimetype": "image/png",
|
||||
"url": "/uploads/issues/1_1793075446720-365231.png"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ISS-20261025-001",
|
||||
"title": "Kiểm tra đối chiếu số liệu tháng 03/2026",
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# HƯỚNG DẪN CÀI ĐẶT, TRIỂN KHAI & KIỂM THỬ DEMO HỆ THỐNG WEB GIAO BAN BỆNH VIỆN (BẢN GOLANG)
|
||||
|
||||
> **Tài liệu chuyển giao kỹ thuật & Hướng dẫn sử dụng thử nghiệm**
|
||||
> **Dự án**: DH-HIS Quản Lý Giao Ban Y Tế
|
||||
> **Phiên bản ứng dụng**: `v1.0.2` (Golang Engine)
|
||||
> **Ngày cập nhật**: Tháng 10/2026
|
||||
|
||||
---
|
||||
|
||||
## MỤC LỤC
|
||||
1. [Tổng Quan Hệ Thống](#1-tổng-quan-hệ-thống)
|
||||
2. [Yêu Cầu Môi Trường & Hạ Tầng](#2-yêu-cầu-môi-trường--hạ-tầng)
|
||||
3. [Cấu Trúc Thư Mục & Tệp Tin](#3-cấu-trúc-thư-mục--tệp-tin)
|
||||
4. [Hướng Dẫn Cấu Hình Kết Nối CSDL (PostgreSQL)](#4-hướng-dẫn-cấu-hình-kết-nối-csdl-postgresql)
|
||||
5. [Cơ Chế Quản Lý Bản Quyền (License Key)](#5-cơ-chế-quản-lý-bản-quyền-license-key)
|
||||
6. [Hướng Dẫn Triển Khai Chạy Ứng Dụng](#6-hướng-dẫn-triển-khai-chạy-ứng-dụng)
|
||||
- [6.1 Triển khai trên môi trường Windows](#61-triển-khai-trên-môi-trường-windows)
|
||||
- [6.2 Triển khai trên môi trường Linux (Systemd Service)](#62-triển-khai-trên-môi-trường-linux-systemd-service)
|
||||
7. [Kịch Bản Kiểm Thử & Hướng Dẫn Demo Khách Hàng](#7-kịch-bản-kiểm-thử--hướng-dẫn-demo-khách-hàng)
|
||||
- [Kịch bản 1: Kiểm thử Báo cáo Ngoại trú & Phân tích Đa chu kỳ](#kịch-bản-1-kiểm-thử-báo-cáo-ngoại-trú--phân-tích-đa-chu-kỳ)
|
||||
- [Kịch bản 2: Kiểm thử Báo cáo Nội trú, Chuyển viện & Ca nặng](#kịch-bản-2-kiểm-thử-báo-cáo-nội-trú-chuyển-viện--ca-nặng)
|
||||
- [Kịch bản 3: Lập Biên bản Giao ban A4 & Phân quyền 3 cấp](#kịch-bản-3-lập-biên-bản-giao-ban-a4--phân-quyền-3-cấp)
|
||||
- [Kịch bản 4: Kiểm tra Chức năng Bản quyền & Quản lý Issue](#kịch-bản-4-kiểm-tra-chức-năng-bản-quyền--quản-lý-issue)
|
||||
8. [Thông Tin CSDL Mẫu Dùng Kiểm Thử Thực Tế](#8-thông-tin-csdl-mẫu-dùng-kiểm-thử-thực-tế)
|
||||
|
||||
---
|
||||
|
||||
## 1. TỔNG QUAN HỆ THỐNG
|
||||
|
||||
Phần mềm **Web Giao Ban Bệnh Viện (Bản Golang)** là giải pháp chuyển đổi số hỗ trợ Ban Lãnh đạo bệnh viện, Phòng Kế hoạch Tổng hợp và các Trưởng/Phó khoa tổ chức họp giao ban đầu giờ buổi sáng:
|
||||
- **Tốc độ vượt trội**: Viết hoàn toàn bằng ngôn ngữ Go (Golang), binary biên dịch tĩnh độc lập, khởi động tức thì, dung lượng RAM tiêu thụ chỉ khoảng 15MB - 30MB (nhẹ hơn 10 lần so với các nền tảng script).
|
||||
- **Trích xuất số liệu 24h tự động**: Đọc trực tiếp từ cơ sở dữ liệu HIS (PostgreSQL), tổng hợp số lượt khám ngoại trú (BHYT / Viện phí), bệnh nhân nội trú (cũ, mới, ra viện, chuyển viện, tử vong, hiện còn điều trị, theo dõi nặng, chăm sóc cấp I).
|
||||
- **Phân tích so sánh đa kỳ**: So sánh tức thì với kỳ liền trước, cùng kỳ tháng trước hoặc cùng kỳ năm trước.
|
||||
- **Biên bản giao ban chuẩn A4 Y tế**: Mẫu biên bản chuẩn hành chính y tế, đồng bộ dữ liệu chỉ với 1 click chuột, hỗ trợ phân quyền 3 cấp và in ấn A4 sắc nét.
|
||||
- **Bảo mật bản quyền tích hợp**: Kiểm soát kích hoạt theo mã bệnh viện (`mabvbh`), thời hạn bản quyền (`DateExpires`) và mã hóa chuẩn công nghiệp OpenSSL AES-256-CBC.
|
||||
|
||||
---
|
||||
|
||||
## 2. YÊU CẦU MÔI TRƯỜNG & HẠ TẦNG
|
||||
|
||||
| Thành phần | Yêu cầu tối thiểu | Khuyến nghị |
|
||||
| :--- | :--- | :--- |
|
||||
| **Hệ điều hành** | Windows 10/11, Windows Server 2012 R2+ hoặc Linux (Ubuntu 20.04+, Debian 11+, CentOS 7+) | Windows Server 2019/2022 hoặc Ubuntu Server 22.04 LTS |
|
||||
| **Kiến trúc CPU** | x86_64 (64-bit) | 2 Cores trở lên |
|
||||
| **Bộ nhớ RAM** | 512 MB trống | 2 GB trở lên |
|
||||
| **Dung lượng đĩa** | 100 MB trống cho phần mềm | SSD 10 GB trở lên |
|
||||
| **Cơ sở dữ liệu** | PostgreSQL 9.2 đến 16+ | PostgreSQL 9.6 / 12 / 14 |
|
||||
| **Trình duyệt Web** | Google Chrome, Microsoft Edge, Cốc Cốc, Mozilla Firefox | Phiên bản hiện đại hỗ trợ ES6 |
|
||||
|
||||
---
|
||||
|
||||
## 3. CẤU TRÚC THƯ MỤC & TỆP TIN
|
||||
|
||||
```
|
||||
web-giao-ban-go/
|
||||
├── web-giao-ban # Binary thực thi trên môi trường Linux
|
||||
├── web-giao-ban.exe # File chạy thực thi trên Windows (kèm Icon chuẩn)
|
||||
├── config.json # Tệp cấu hình cổng mạng, CSDL và Bản quyền
|
||||
├── public/ # Thư mục giao diện Web Frontend
|
||||
│ ├── index.html # Trang báo cáo giao ban chính
|
||||
│ ├── app.js # Logic điều khiển báo cáo, biểu đồ, biên bản A4
|
||||
│ ├── admin.html # Trang Quản trị kết nối CSDL và Kích hoạt License
|
||||
│ ├── admin.js # Logic trang quản trị
|
||||
│ ├── favicon.ico # Favicon nhận diện DH-HIS
|
||||
│ └── css / js / ... # Thư viện giao diện (Tailwind, FontAwesome, Chart.js)
|
||||
├── data/ # Lưu trữ dữ liệu hệ thống
|
||||
│ ├── issues.json # Nhật ký báo lỗi và cải tiến (Issue tracking)
|
||||
│ └── uploads/ # Thư mục lưu tệp đính kèm chụp màn hình issue
|
||||
├── docs/ # Tài liệu hướng dẫn triển khai
|
||||
│ └── HUONG_DAN_CAI_DAT_TRIEN_KHAI_DEMO.md
|
||||
└── build.sh # Script biên dịch tự động đa nền tảng
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. HƯỚNG DẪN CẤU HÌNH KẾT NỐI CSDL (POSTGRESQL)
|
||||
|
||||
Mở tệp `config.json` bằng Notepad, VSCode hoặc `nano`:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"port": 3001,
|
||||
"cors_allowed_origins": ["*"]
|
||||
},
|
||||
"databases": [
|
||||
{
|
||||
"id": "an_omon_full_04052026",
|
||||
"name": "BVĐK Ô Môn (33GB)",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"user": "postgres",
|
||||
"password": "ENC(U2FsdGVkX1+...)",
|
||||
"dbname": "an_omon_full_04052026",
|
||||
"sslmode": "disable"
|
||||
},
|
||||
{
|
||||
"id": "backup_govap_20260408_010001",
|
||||
"name": "BVĐK Gò Vấp (65GB)",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"user": "postgres",
|
||||
"password": "ENC(U2FsdGVkX1+...)",
|
||||
"dbname": "backup_govap_20260408_010001",
|
||||
"sslmode": "disable"
|
||||
}
|
||||
],
|
||||
"license": {
|
||||
"crypto_key": "ENC(U2FsdGVkX18...)",
|
||||
"active_key": "U2FsdGVkX18Zsq52FPbB7FS1QM7CwWh+t1Esi96gUMFILJD0vZXM69uQBC4sP7TjLVOiJPZt0KIq17iql+hXehLCVSbCEhwKKm+zNab+u2Swah63A52hz0KMJXddLRZ6vanmP2goLTzY7749HsFVRDq+3bdbi8fdUZCpAsSTEiqC2cRTpF5WVh0SHt1wX/i42YSQgOYPeXQtg+lRx/URk4p7lYSVQOYWXglp99kO+Va1ADoNcTgrfz3aaAxAkj0S"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> - Mật khẩu CSDL (`password`) và Passphrase giải mã (`crypto_key`) được hỗ trợ lưu dạng mã hóa `ENC(...)`. Nếu nhập dạng chuỗi thông thường (Plaintext), hệ thống vẫn tự động nhận diện và kết nối an toàn.
|
||||
> - Có thể thêm bao nhiêu CSDL tùy ý vào mảng `databases`. Người dùng có thể chuyển đổi linh hoạt ngay trên dropdown của thanh công cụ Web.
|
||||
|
||||
---
|
||||
|
||||
## 5. CƠ CHẾ QUẢN LÝ BẢN QUYỀN (LICENSE KEY)
|
||||
|
||||
### 5.1 Quy cách và Cấu trúc Bản quyền
|
||||
Hệ thống sử dụng cơ chế bảo mật theo tiêu chuẩn thư viện `OTH.Adapter / HisPrivateCode`:
|
||||
- **Thuật toán**: OpenSSL EVP_BytesToKey (MD5 key derivation + AES-256-CBC).
|
||||
- **Khóa dẫn xuất nội bộ**: `BAD5A05F330D5B8CDDFCA9E7ECDC840B`.
|
||||
- **Cấu trúc dữ liệu JSON sau giải mã**:
|
||||
```json
|
||||
{
|
||||
"AtRow": 3,
|
||||
"mabvbh": "84006",
|
||||
"DateExpires": "2027-01-30",
|
||||
"key": "web_giao_ban",
|
||||
"title": "Sử dụng Web giao ban",
|
||||
"TenBV": "Trà Cú",
|
||||
"Đối tác": "dh",
|
||||
"Ghi chú": "Trial"
|
||||
}
|
||||
```
|
||||
- **Quy tắc kiểm duyệt**:
|
||||
1. Trường `key` phải bằng `"web_giao_ban"`.
|
||||
2. Thời hạn `DateExpires` phải lớn hơn hoặc bằng ngày hiện tại.
|
||||
3. Nếu chưa kích hoạt hoặc key hết hạn: Toàn bộ API báo cáo ngoại trú, nội trú và dữ liệu KCB sẽ trả về cảnh báo chặn HTTP 403; giao diện người dùng hiển thị modal hướng dẫn kích hoạt bản quyền.
|
||||
|
||||
### 5.2 Key Bản Quyền Mẫu Dùng Thử Nghiệm (DECODE Token)
|
||||
Khi thao tác nhập trên giao diện Web Quản trị, người dùng chỉ cần copy và dán duy nhất chuỗi token DECODE bên dưới:
|
||||
|
||||
```
|
||||
U2FsdGVkX18Zsq52FPbB7FS1QM7CwWh+t1Esi96gUMFILJD0vZXM69uQBC4sP7TjLVOiJPZt0KIq17iql+hXehLCVSbCEhwKKm+zNab+u2Swah63A52hz0KMJXddLRZ6vanmP2goLTzY7749HsFVRDq+3bdbi8fdUZCpAsSTEiqC2cRTpF5WVh0SHt1wX/i42YSQgOYPeXQtg+lRx/URk4p7lYSVQOYWXglp99kO+Va1ADoNcTgrfz3aaAxAkj0S
|
||||
```
|
||||
|
||||
### 5.3 Cách Kích Hoạt Bản Quyền Trên Web
|
||||
1. Mở trình duyệt truy cập: `http://<IP-SERVER>:3001/admin.html`.
|
||||
2. Chọn thẻ **"Tab 4: Quản Lý Bản Quyền (License)"**.
|
||||
3. Dán chuỗi DECODE token mẫu vào ô văn bản, sau đó bấm nút **"Kích Hoạt Key Bản Quyền"**.
|
||||
4. Màn hình sẽ hiển thị thẻ thông tin màu xanh: Đơn vị sở hữu (**BV Trà Cú**), Hạn sử dụng (**30/01/2027**), Trạng thái (**Hợp lệ**).
|
||||
|
||||
---
|
||||
|
||||
## 6. HƯỚNG DẪN TRIỂN KHAI CHẠY ỨNG DỤNG
|
||||
|
||||
### 6.1 Triển khai trên môi trường Windows
|
||||
|
||||
1. **Chuẩn bị thư mục**:
|
||||
- Giải nén toàn bộ thư mục `web-giao-ban-go` vào ổ đĩa bất kỳ (ví dụ: `D:\HIS\web-giao-ban-go\`).
|
||||
2. **Kiểm tra kết nối CSDL**:
|
||||
- Mở tệp `config.json`, điều chỉnh `host`, `port`, `user`, `password` trỏ đến máy chủ PostgreSQL đang lưu trữ CSDL HIS.
|
||||
3. **Khởi chạy ứng dụng**:
|
||||
- Nhấp đúp chuột vào tệp `web-giao-ban.exe` (đã được đính kèm icon nhận diện chuyên nghiệp).
|
||||
- Một cửa sổ dòng lệnh hiển thị thông báo:
|
||||
```
|
||||
[INFO] DH-HIS Web Giao Ban v1.0.2 đang lắng nghe tại cổng :3001
|
||||
[INFO] CSDL hiện hữu: BVĐK Ô Môn, BVĐK Gò Vấp
|
||||
[INFO] Bản quyền: BV Trà Cú - Hạn dùng đến 2027-01-30
|
||||
```
|
||||
4. **Cài đặt chạy ngầm tự khởi động cùng Windows (Tùy chọn)**:
|
||||
- Sử dụng công cụ `nssm` (Non-Sucking Service Manager):
|
||||
```cmd
|
||||
nssm install DHWebGiaoBan "D:\HIS\web-giao-ban-go\web-giao-ban.exe"
|
||||
nssm set DHWebGiaoBan AppDirectory "D:\HIS\web-giao-ban-go"
|
||||
nssm start DHWebGiaoBan
|
||||
```
|
||||
|
||||
### 6.2 Triển khai trên môi trường Linux (Systemd Service)
|
||||
|
||||
1. **Phân quyền thực thi**:
|
||||
```bash
|
||||
chmod +x /home/othau/dh-coding/web-giao-ban-go/web-giao-ban
|
||||
```
|
||||
2. **Tạo dịch vụ quản lý Systemd**:
|
||||
Tạo tệp cấu hình `/etc/systemd/system/web-giao-ban-go.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=DH-HIS Web Giao Ban Go Service
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=othau
|
||||
WorkingDirectory=/home/othau/dh-coding/web-giao-ban-go
|
||||
ExecStart=/home/othau/dh-coding/web-giao-ban-go/web-giao-ban
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PORT=3001
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
3. **Kích hoạt và khởi chạy dịch vụ**:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable web-giao-ban-go
|
||||
sudo systemctl restart web-giao-ban-go
|
||||
sudo systemctl status web-giao-ban-go
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. KỊCH BẢN KIỂM THỬ & HƯỚNG DẪN DEMO KHÁCH HÀNG
|
||||
|
||||
### Kịch bản 1: Kiểm thử Báo cáo Ngoại trú & Phân tích Đa chu kỳ
|
||||
1. **Truy cập hệ thống**: Mở trình duyệt vào địa chỉ `http://localhost:3001/` (hoặc domain demo nội bộ).
|
||||
2. **Khóa/Mở ô chọn ngày (`ISS-20261026-001`)**:
|
||||
- Chọn mốc nhanh: *"Hôm nay (24h)"*, *"Tháng 01/2026"*, *"Tháng 02/2026"*.
|
||||
*Quan sát*: Hai ô Từ ngày và Đến ngày lập tức được tô xám và có nhãn **"Cố định"**, người dùng không bị gõ nhầm ngày.
|
||||
- Chọn mốc *"Tùy chọn khoảng ngày..."*: Hai ô ngày chuyển màu trắng, có nhãn **"Mở khóa"** để tùy biến ngày giờ.
|
||||
3. **Xem số liệu khám chữa bệnh**:
|
||||
- Bấm nút **"Xem Báo Cáo"**: Các thẻ KPI (Tổng lượt khám, Khám BHYT, Khám Thu phí, Chuyển tuyến) hiển thị số liệu thực tế.
|
||||
4. **Chuyển đổi kiểu biểu đồ Ngoại trú (`ISS-20261026-002`)**:
|
||||
- Bấm nút **"Cột Khoa"**: Xem số lượt khám các phòng khám đông nhất.
|
||||
- Bấm nút **"% Tròn Khoa"**: Hiển thị biểu đồ hình tròn (Doughnut) phân tích tỷ lệ phần trăm thị phần khám của từng khoa.
|
||||
- Bấm nút **"BHYT / Thu phí"**: Biểu đồ cột nhóm trực quan phân định rõ cơ cấu doanh thu BHYT vs Viện phí.
|
||||
- Bấm nút **"Theo CLS"**: Biểu đồ thống kê lượt Xét nghiệm, Siêu âm, X-quang, Điện tim...
|
||||
5. **So sánh đa chu kỳ (`ISS-20261027-003` & `ISS-20261027-004`)**:
|
||||
- Tích chọn checkbox **"Bật chế độ đối chiếu đa chu kỳ"**.
|
||||
- Thử chọn dropdown nhanh: *"So với cùng kỳ tháng trước"*, *"So với cùng kỳ năm trước"*.
|
||||
- Tích chọn checkbox **"Ẩn dòng không có dữ liệu (2 kỳ = 0)"**: Bảng tự động giấu các phòng khám không phát sinh lượt, giúp bản in gọn gàng.
|
||||
|
||||
---
|
||||
|
||||
### Kịch bản 2: Kiểm thử Báo cáo Nội trú, Chuyển viện & Ca nặng
|
||||
1. Chuyển sang **"Tab 2: Báo Cáo Giao Ban Nội Trú"**.
|
||||
2. **Kiểm tra các biểu đồ chuyên sâu Nội trú (`ISS-20261027-001`)**:
|
||||
- Bấm **"Cột Vào Viện"**: Xem số ca nhập viện và ra viện của các khoa lâm sàng.
|
||||
- Bấm **"% Tròn 4 Tiêu Chí"**: Biểu đồ hình tròn thể hiện rõ tương quan 4 tiêu chí toàn viện (Bệnh cũ, Mới nhập, Xuất viện, Hiện còn).
|
||||
- Bấm **"% Hiện Còn Theo Khoa"**: Đánh giá áp lực giường bệnh và tỷ lệ điều trị của từng khoa.
|
||||
- Bấm **"Cột Nhóm Tổng Hợp"**: Cột nhóm 4 tiêu chí song song cho từng khoa nội trú.
|
||||
3. **Bảng 3 - Bệnh nhân chuyển tuyến**:
|
||||
- Xem danh sách chi tiết: Mã bệnh nhân, Họ tên, Chẩn đoán, Kết quả cận lâm sàng.
|
||||
- Nhập ghi chú giao ban vào ô văn bản (ví dụ: *"Đã liên hệ BV tuyến trên tiếp nhận an toàn"*). Dữ liệu được lưu trữ tự động.
|
||||
4. **Bảng 4 - Bệnh nhân nặng theo dõi đặc biệt & Chăm sóc Cấp I**:
|
||||
- Theo dõi số ngày điều trị, phân loại mức độ và ghi chú chỉ đạo chuyên môn của trực lãnh đạo.
|
||||
|
||||
---
|
||||
|
||||
### Kịch bản 3: Lập Biên bản Giao ban A4 & Phân quyền 3 cấp (`ISS-20261027-002`)
|
||||
1. Chuyển sang **"Tab 3: Biên Bản Giao Ban"**.
|
||||
2. **Kiểm tra tính năng Đồng bộ 24h tự động**:
|
||||
- Bấm nút **"Đồng bộ số liệu 24h"**: Hệ thống tự động trích xuất các chỉ số ngoại trú, nội trú, chuyển viện điền thẳng vào Mục I của biên bản.
|
||||
3. **Kiểm tra Phân quyền 3 cấp**:
|
||||
- **Cấp 1 (Bác sĩ / Nhân viên)**: Chọn vai trò Cấp 1 $\rightarrow$ Toàn bộ văn bản chuyển sang chế độ CHỈ XEM (Read-only), các nút Lưu, Đồng bộ và Khóa bị ẩn hoàn toàn.
|
||||
- **Cấp 2 (Thư ký giao ban)**: Được quyền chỉnh sửa Mục I đến Mục IV, không được sửa Mục V (Chỉ đạo BGĐ) và không có quyền bấm Khóa.
|
||||
- **Cấp 3 (Chủ trì / Ban Giám Đốc)**: Được quyền kết luận Mục V, sửa toàn bộ và bấm nút **"Khóa biên bản"**. Khi đã khóa, văn bản được niêm phong tránh sửa đổi ngoài ý muốn.
|
||||
4. **In ấn chuẩn A4**:
|
||||
- Bấm nút **"In Biên Bản A4"**: Giao diện tự động tối ưu phông chữ, ẩn thanh công cụ, chia trang vừa vặn chuẩn văn bản y tế có sẵn đầy đủ chữ ký Thư ký và Lãnh đạo duyệt.
|
||||
|
||||
---
|
||||
|
||||
### Kịch bản 4: Kiểm tra Chức năng Bản quyền & Quản lý Issue
|
||||
1. **Kiểm tra Bản quyền**:
|
||||
- Góc trên thanh tiêu đề hiển thị huy hiệu:
|
||||
`BV Trà Cú (web_giao_ban) - HSD: 2027-01-30`.
|
||||
- Vào `admin.html#tab-license`: Thử kiểm tra thông tin, hoặc thử dán key không đúng định dạng để xem thông báo chặn lỗi bảo mật.
|
||||
2. **Quản lý Issue & Báo lỗi**:
|
||||
- Chuyển sang **"Tab 4: Quản Lý Issue & Báo Lỗi"**.
|
||||
- Bấm nút **"Tạo Báo Lỗi / Đề Xuất Mới"**: Điền nội dung, đính kèm ảnh chụp màn hình, phân loại mức độ và gửi lưu.
|
||||
- Bấm **"Xuất Excel Danh Sách"** để tải file Excel tổng hợp các vấn đề cần xử lý.
|
||||
|
||||
---
|
||||
|
||||
## 8. THÔNG TIN CSDL MẪU DÙNG KIỂM THỬ THỰC TẾ
|
||||
|
||||
Hệ thống đã nạp sẵn cấu hình kết nối tới 2 bộ cơ sở dữ liệu lớn thực tế để kiểm thử hiệu năng truy vấn:
|
||||
|
||||
1. **Cơ sở dữ liệu Bệnh viện Đa khoa Ô Môn**:
|
||||
- ID định danh: `an_omon_full_04052026`
|
||||
- Dung lượng CSDL: **33 GB**
|
||||
- Khoảng thời gian dữ liệu đầy đủ nhất: **Tháng 01/2026** và **Tháng 02/2026** (Chọn preset có sẵn để nạp số liệu tức thì).
|
||||
2. **Cơ sở dữ liệu Bệnh viện Đa khoa Gò Vấp**:
|
||||
- ID định danh: `backup_govap_20260408_010001`
|
||||
- Dung lượng CSDL: **65 GB**
|
||||
- Dùng để đối chiếu hiệu năng khi xử lý khối lượng bệnh án và lượt khám lớn.
|
||||
|
||||
---
|
||||
|
||||
> **Hỗ trợ kỹ thuật & Nâng cấp tính năng**:
|
||||
> Đội ngũ phát triển phần mềm DH-HIS
|
||||
> Gitea Issue Tracker: `https://i.dh-his.com/hdhiswork/DUAN/issues`
|
||||
@@ -2,12 +2,14 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"web-giao-ban-go/config"
|
||||
"web-giao-ban-go/db"
|
||||
"web-giao-ban-go/license"
|
||||
"web-giao-ban-go/logger"
|
||||
"web-giao-ban-go/updater"
|
||||
)
|
||||
@@ -28,6 +30,9 @@ func HandleGetAdminConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if respCfg.Updater.Token != "" {
|
||||
respCfg.Updater.Token = "••••••••"
|
||||
}
|
||||
if respCfg.License.CryptoKey != "" {
|
||||
respCfg.License.CryptoKey = "••••••••"
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -78,6 +83,19 @@ func HandleSaveAdminConfig(w http.ResponseWriter, r *http.Request) {
|
||||
input.Updater.Token = enc
|
||||
}
|
||||
|
||||
// Handle License Crypto Key
|
||||
if input.License.CryptoKey == "" || input.License.CryptoKey == "••••••••" {
|
||||
input.License.CryptoKey = current.License.CryptoKey
|
||||
input.DecryptedCryptoKey = current.DecryptedCryptoKey
|
||||
} else {
|
||||
input.DecryptedCryptoKey = input.License.CryptoKey
|
||||
enc, _ := config.Encrypt(input.License.CryptoKey)
|
||||
input.License.CryptoKey = enc
|
||||
}
|
||||
if input.License.Key == "" {
|
||||
input.License.Key = current.License.Key
|
||||
}
|
||||
|
||||
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()})
|
||||
@@ -216,3 +234,55 @@ func HandleApplyUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
"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.",
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/license/status
|
||||
func HandleGetLicenseStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
status := license.GetStatus()
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": status,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/license/activate
|
||||
func HandleActivateLicense(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
var body struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
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 JSON không hợp lệ: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(body.Key)
|
||||
if key == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "Vui lòng nhập chuỗi key bản quyền (chỉ nhập chuỗi mã hóa DECODE)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := license.Activate(key)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "Kích hoạt thất bại: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("Kích hoạt bản quyền thành công cho đơn vị '%s' (Mã BV: %s)!", info.TenBV, info.Mabvbh),
|
||||
"data": info,
|
||||
})
|
||||
}
|
||||
|
||||
+37
-2
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"web-giao-ban-go/config"
|
||||
"web-giao-ban-go/db"
|
||||
"web-giao-ban-go/license"
|
||||
)
|
||||
|
||||
// Helper to generate partition filters: namkt IN (...) AND thangkt IN (...)
|
||||
@@ -61,13 +62,32 @@ func GetThangNamFilter(fromStr, toStr, prefix string) string {
|
||||
)
|
||||
}
|
||||
|
||||
func checkLicense(w http.ResponseWriter) bool {
|
||||
valid, msg := license.CheckValid()
|
||||
if !valid {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"license_valid": false,
|
||||
"message": msg,
|
||||
"error": msg,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GET /api/databases
|
||||
func HandleGetDatabases(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := config.Get()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
licStatus := license.GetStatus()
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": cfg.Database.Databases,
|
||||
"success": true,
|
||||
"data": cfg.Database.Databases,
|
||||
"license_valid": licStatus.IsValid,
|
||||
"license": licStatus,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -297,6 +317,9 @@ func queryOutpatient(database, fromDate, toDate string) (*OutpatientData, error)
|
||||
|
||||
// GET /api/giao-ban-ngoai-tru
|
||||
func HandleGiaoBanNgoaiTru(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkLicense(w) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
q := r.URL.Query()
|
||||
database := q.Get("database")
|
||||
@@ -399,6 +422,9 @@ func queryInpatient(database, fromDate, toDate string) (*InpatientData, error) {
|
||||
|
||||
// GET /api/giao-ban-noi-tru
|
||||
func HandleGiaoBanNoiTru(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkLicense(w) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
q := r.URL.Query()
|
||||
database := q.Get("database")
|
||||
@@ -448,6 +474,9 @@ func HandleGiaoBanNoiTru(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// GET /api/danh-sach-chuyen-vien
|
||||
func HandleDanhSachChuyenVien(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkLicense(w) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
q := r.URL.Query()
|
||||
database := q.Get("database")
|
||||
@@ -538,6 +567,9 @@ func HandleDanhSachChuyenVien(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// GET /api/danh-sach-theo-doi
|
||||
func HandleDanhSachTheoDoi(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkLicense(w) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
q := r.URL.Query()
|
||||
database := q.Get("database")
|
||||
@@ -636,6 +668,9 @@ func HandleDanhSachTheoDoi(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// POST /api/cap-nhat-ghi-chu
|
||||
func HandleCapNhatGhiChu(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkLicense(w) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
var body struct {
|
||||
Maba string `json:"maba"`
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"web-giao-ban-go/config"
|
||||
"web-giao-ban-go/logger"
|
||||
)
|
||||
|
||||
type LicenseInfo struct {
|
||||
AtRow int `json:"AtRow,omitempty"`
|
||||
Mabvbh string `json:"mabvbh"`
|
||||
DateExpires string `json:"DateExpires"` // YYYY-MM-DD
|
||||
Key string `json:"key"` // e.g. "web_giao_ban"
|
||||
Title string `json:"title"`
|
||||
TenBV string `json:"TenBV"`
|
||||
DoiTac string `json:"Đối tác,omitempty"`
|
||||
GhiChu string `json:"Ghi chú,omitempty"`
|
||||
UseHddtBy string `json:"UseHddtBy,omitempty"`
|
||||
Trial string `json:"Trial,omitempty"`
|
||||
NgayHetHan string `json:"Ngày hết hạn,omitempty"`
|
||||
|
||||
// Evaluation fields
|
||||
IsValid bool `json:"is_valid"`
|
||||
RemainingDays int `json:"remaining_days"`
|
||||
StatusMessage string `json:"status_message"`
|
||||
RawEncrypted string `json:"raw_encrypted,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
licenseMu sync.RWMutex
|
||||
currentLicense *LicenseInfo
|
||||
)
|
||||
|
||||
// DeriveKeyAndIV implements OpenSSL's EVP_BytesToKey with MD5 for AES-256-CBC
|
||||
func DeriveKeyAndIV(passphrase []byte, salt []byte, keyLen int, ivLen int) ([]byte, []byte) {
|
||||
totalLen := keyLen + ivLen
|
||||
var concatenatedHashes []byte
|
||||
var currentHash []byte
|
||||
|
||||
for len(concatenatedHashes) < totalLen {
|
||||
hasher := md5.New()
|
||||
if len(currentHash) > 0 {
|
||||
hasher.Write(currentHash)
|
||||
}
|
||||
hasher.Write(passphrase)
|
||||
hasher.Write(salt)
|
||||
currentHash = hasher.Sum(nil)
|
||||
concatenatedHashes = append(concatenatedHashes, currentHash...)
|
||||
}
|
||||
|
||||
key := concatenatedHashes[:keyLen]
|
||||
iv := concatenatedHashes[keyLen : keyLen+ivLen]
|
||||
return key, iv
|
||||
}
|
||||
|
||||
// OpenSSLDecrypt decrypts a Base64 string created by CryptoJS / OpenSSL
|
||||
func OpenSSLDecrypt(encryptedBase64 string, passphrase string) (string, error) {
|
||||
clean := strings.TrimSpace(encryptedBase64)
|
||||
clean = strings.ReplaceAll(clean, "\r", "")
|
||||
clean = strings.ReplaceAll(clean, "\n", "")
|
||||
clean = strings.ReplaceAll(clean, " ", "")
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(clean)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lỗi giải mã base64: %v", err)
|
||||
}
|
||||
|
||||
if len(data) < 16 || string(data[:8]) != "Salted__" {
|
||||
return "", errors.New("định dạng key không đúng (thiếu header OpenSSL Salted__)")
|
||||
}
|
||||
|
||||
salt := data[8:16]
|
||||
ciphertext := data[16:]
|
||||
|
||||
key, iv := DeriveKeyAndIV([]byte(passphrase), salt, 32, 16)
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return "", errors.New("kích thước dữ liệu mã hóa không hợp lệ (không phải bội số 16)")
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
mode.CryptBlocks(plaintext, ciphertext)
|
||||
|
||||
// PKCS7 unpadding
|
||||
if len(plaintext) == 0 {
|
||||
return "", errors.New("dữ liệu giải mã rỗng")
|
||||
}
|
||||
padLen := int(plaintext[len(plaintext)-1])
|
||||
if padLen < 1 || padLen > aes.BlockSize || padLen > len(plaintext) {
|
||||
return "", errors.New("lỗi padding - mật khẩu giải mã không đúng hoặc chuỗi key bị sai")
|
||||
}
|
||||
for i := len(plaintext) - padLen; i < len(plaintext); i++ {
|
||||
if plaintext[i] != byte(padLen) {
|
||||
return "", errors.New("lỗi padding bytes - mật khẩu giải mã không đúng hoặc chuỗi key bị sai")
|
||||
}
|
||||
}
|
||||
|
||||
return string(plaintext[:len(plaintext)-padLen]), nil
|
||||
}
|
||||
|
||||
// ValidateAndParseLicense parses and evaluates the license ciphertext
|
||||
func ValidateAndParseLicense(encryptedKey string, cryptoKey string) (*LicenseInfo, error) {
|
||||
if strings.TrimSpace(encryptedKey) == "" {
|
||||
return nil, errors.New("chuỗi key bản quyền trống")
|
||||
}
|
||||
if strings.TrimSpace(cryptoKey) == "" {
|
||||
cryptoKey = "BAD5A05F330D5B8CDDFCA9E7ECDC840B"
|
||||
}
|
||||
|
||||
decryptedJSON, err := OpenSSLDecrypt(encryptedKey, cryptoKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("không thể giải mã key bản quyền: %v", err)
|
||||
}
|
||||
|
||||
var info LicenseInfo
|
||||
if err := json.Unmarshal([]byte(decryptedJSON), &info); err != nil {
|
||||
return nil, fmt.Errorf("dữ liệu JSON bản quyền không hợp lệ: %v", err)
|
||||
}
|
||||
|
||||
// Verify required fields
|
||||
if info.Key != "web_giao_ban" {
|
||||
return nil, fmt.Errorf("key bản quyền không dùng cho chức năng 'web_giao_ban' (nhận diện: %s)", info.Key)
|
||||
}
|
||||
if info.DateExpires == "" {
|
||||
return nil, errors.New("key bản quyền không có thông tin hạn dùng (DateExpires)")
|
||||
}
|
||||
|
||||
// Parse expiration date (YYYY-MM-DD)
|
||||
expireDate, err := time.Parse("2006-01-02", strings.TrimSpace(info.DateExpires))
|
||||
if err != nil {
|
||||
// try DD/MM/YYYY
|
||||
expireDate, err = time.Parse("02/01/2006", strings.TrimSpace(info.DateExpires))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("định dạng hạn dùng không hợp lệ (%s)", info.DateExpires)
|
||||
}
|
||||
}
|
||||
|
||||
// Compare with today (start of current day in local time)
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
expireDay := time.Date(expireDate.Year(), expireDate.Month(), expireDate.Day(), 23, 59, 59, 0, now.Location())
|
||||
|
||||
daysRemaining := int(expireDay.Sub(today).Hours() / 24)
|
||||
info.RemainingDays = daysRemaining
|
||||
info.RawEncrypted = encryptedKey
|
||||
|
||||
if expireDay.Before(today) {
|
||||
info.IsValid = false
|
||||
info.StatusMessage = fmt.Sprintf("Key bản quyền đã hết hạn vào ngày %s (quá hạn %d ngày)", info.DateExpires, -daysRemaining)
|
||||
} else {
|
||||
info.IsValid = true
|
||||
info.StatusMessage = fmt.Sprintf("Key bản quyền hợp lệ đến ngày %s (còn lại %d ngày)", info.DateExpires, daysRemaining)
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// InitLicense loads the license from config and evaluates it
|
||||
func InitLicense() {
|
||||
licenseMu.Lock()
|
||||
defer licenseMu.Unlock()
|
||||
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cryptoKey := cfg.DecryptedCryptoKey
|
||||
if cryptoKey == "" {
|
||||
cryptoKey = "BAD5A05F330D5B8CDDFCA9E7ECDC840B"
|
||||
}
|
||||
|
||||
if cfg.License.Key == "" {
|
||||
currentLicense = &LicenseInfo{
|
||||
IsValid: false,
|
||||
StatusMessage: "Chưa cập nhật key bản quyền tính năng",
|
||||
}
|
||||
logger.Warn("Chưa cập nhật key bản quyền web giao ban")
|
||||
return
|
||||
}
|
||||
|
||||
info, err := ValidateAndParseLicense(cfg.License.Key, cryptoKey)
|
||||
if err != nil {
|
||||
currentLicense = &LicenseInfo{
|
||||
IsValid: false,
|
||||
StatusMessage: fmt.Sprintf("Key bản quyền không hợp lệ: %v", err),
|
||||
RawEncrypted: cfg.License.Key,
|
||||
}
|
||||
logger.Error("Kiểm tra key bản quyền thất bại: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
currentLicense = info
|
||||
if info.IsValid {
|
||||
logger.Info("Bản quyền hợp lệ: Đơn vị '%s' (%s) - Hạn dùng: %s (%d ngày)", info.TenBV, info.Mabvbh, info.DateExpires, info.RemainingDays)
|
||||
} else {
|
||||
logger.Warn("Bản quyền không hợp lệ: %s", info.StatusMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns the current license status
|
||||
func GetStatus() *LicenseInfo {
|
||||
licenseMu.RLock()
|
||||
defer licenseMu.RUnlock()
|
||||
|
||||
if currentLicense == nil {
|
||||
return &LicenseInfo{
|
||||
IsValid: false,
|
||||
StatusMessage: "Chưa nạp thông tin bản quyền",
|
||||
}
|
||||
}
|
||||
return currentLicense
|
||||
}
|
||||
|
||||
// Activate saves and applies a new license key
|
||||
func Activate(encryptedKey string) (*LicenseInfo, error) {
|
||||
cfg := config.Get()
|
||||
cryptoKey := cfg.DecryptedCryptoKey
|
||||
if cryptoKey == "" {
|
||||
cryptoKey = "BAD5A05F330D5B8CDDFCA9E7ECDC840B"
|
||||
}
|
||||
|
||||
info, err := ValidateAndParseLicense(encryptedKey, cryptoKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !info.IsValid {
|
||||
return nil, errors.New(info.StatusMessage)
|
||||
}
|
||||
|
||||
// Update config
|
||||
licenseMu.Lock()
|
||||
currentLicense = info
|
||||
licenseMu.Unlock()
|
||||
|
||||
cfg.License.Key = strings.TrimSpace(encryptedKey)
|
||||
if err := config.SaveConfig(cfg); err != nil {
|
||||
return nil, fmt.Errorf("lưu cấu hình thất bại: %v", err)
|
||||
}
|
||||
|
||||
logger.Info("Kích hoạt bản quyền thành công cho đơn vị: %s (%s), hạn dùng: %s", info.TenBV, info.Mabvbh, info.DateExpires)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CheckValid verifies if license is currently valid
|
||||
func CheckValid() (bool, string) {
|
||||
licenseMu.RLock()
|
||||
defer licenseMu.RUnlock()
|
||||
|
||||
if currentLicense == nil || !currentLicense.IsValid {
|
||||
msg := "Chưa cập nhật key bản quyền hoặc key đã hết hạn sử dụng. Vui lòng liên hệ bộ phận hỗ trợ kỹ thuật."
|
||||
if currentLicense != nil && currentLicense.StatusMessage != "" {
|
||||
msg = currentLicense.StatusMessage
|
||||
}
|
||||
return false, msg
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenSSLDecryptUserKey(t *testing.T) {
|
||||
cipherText := "U2FsdGVkX18Zsq52FPbB7FS1QM7CwWh+t1Esi96gUMFILJD0vZXM69uQBC4sP7TjLVOiJPZt0KIq17iql+hXehLCVSbCEhwKKm+zNab+u2Swah63A52hz0KMJXddLRZ6vanmP2goLTzY7749HsFVRDq+3bdbi8fdUZCpAsSTEiqC2cRTpF5WVh0SHt1wX/i42YSQgOYPeXQtg+lRx/URk4p7lYSVQOYWXglp99kO+Va1ADoNcTgrfz3aaAxAkj0S"
|
||||
passphrase := "BAD5A05F330D5B8CDDFCA9E7ECDC840B"
|
||||
|
||||
info, err := ValidateAndParseLicense(cipherText, passphrase)
|
||||
if err != nil {
|
||||
t.Fatalf("Validation failed: %v", err)
|
||||
}
|
||||
|
||||
if info.Mabvbh != "84006" {
|
||||
t.Errorf("Expected Mabvbh 84006, got %s", info.Mabvbh)
|
||||
}
|
||||
if info.Key != "web_giao_ban" {
|
||||
t.Errorf("Expected Key web_giao_ban, got %s", info.Key)
|
||||
}
|
||||
if info.TenBV != "Trà Cú" {
|
||||
t.Errorf("Expected TenBV Trà Cú, got %s", info.TenBV)
|
||||
}
|
||||
if info.DateExpires != "2027-01-30" {
|
||||
t.Errorf("Expected DateExpires 2027-01-30, got %s", info.DateExpires)
|
||||
}
|
||||
if !info.IsValid {
|
||||
t.Errorf("Expected IsValid true, got false (%s)", info.StatusMessage)
|
||||
}
|
||||
t.Logf("Decrypted successfully: %+v", info)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"web-giao-ban-go/config"
|
||||
"web-giao-ban-go/handlers"
|
||||
"web-giao-ban-go/license"
|
||||
"web-giao-ban-go/logger"
|
||||
"web-giao-ban-go/tray"
|
||||
)
|
||||
@@ -34,6 +35,9 @@ func main() {
|
||||
logger.Info("Đã nạp file cấu hình thành công (Port: %d, Admin: %s)", cfg.Server.Port, cfg.Server.AdminPath)
|
||||
}
|
||||
|
||||
// Initialize License
|
||||
license.InitLicense()
|
||||
|
||||
handlers.EnsureDirs()
|
||||
|
||||
// Run system tray (Windows GUI) or signal listener (Linux daemon)
|
||||
@@ -184,6 +188,10 @@ func startWebServer() {
|
||||
mountAPI("/api/update/check", handlers.HandleCheckUpdate)
|
||||
mountAPI("/api/update/apply", handlers.HandleApplyUpdate)
|
||||
|
||||
// License APIs
|
||||
mountAPI("/api/license/status", handlers.HandleGetLicenseStatus)
|
||||
mountAPI("/api/license/activate", handlers.HandleActivateLicense)
|
||||
|
||||
// Static website routes
|
||||
mux.HandleFunc("/web-giao-ban-go", staticHandler)
|
||||
mux.HandleFunc("/web-giao-ban-go/", staticHandler)
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
<i class="fa-solid fa-terminal"></i>
|
||||
<span>3. Nhật Ký Hoạt Động (Live Logs)</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-license">
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
<span>4. Quản Lý Bản Quyền (License)</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -332,6 +336,99 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ========================================== -->
|
||||
<!-- TAB 4: QUẢN LÝ BẢN QUYỀN (LICENSE) -->
|
||||
<!-- ========================================== -->
|
||||
<div id="tab-license" class="admin-content hidden space-y-6">
|
||||
|
||||
<!-- License Status Overview -->
|
||||
<div class="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-certificate text-amber-500"></i>
|
||||
<span>Thông Tin Bản Quyền Web Giao Ban (DH-HIS)</span>
|
||||
</h2>
|
||||
<span id="licenseStatusBadge" class="text-xs px-2.5 py-1 rounded-full font-bold bg-slate-100 text-slate-600">Đang kiểm tra...</span>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
||||
<div class="bg-slate-50 p-4 rounded-xl border border-slate-200">
|
||||
<span class="text-xs text-slate-500 block font-medium mb-1">Tên Bệnh Viện / Đơn Vị:</span>
|
||||
<span id="licTenBV" class="text-sm font-bold text-slate-800 block">--</span>
|
||||
<span class="text-[11px] text-slate-400 font-mono mt-0.5 block">Mã BVBH: <b id="licMabvbh" class="text-indigo-600">--</b></span>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 p-4 rounded-xl border border-slate-200">
|
||||
<span class="text-xs text-slate-500 block font-medium mb-1">Thời Hạn Sử Dụng:</span>
|
||||
<span id="licDateExpires" class="text-sm font-bold text-slate-800 font-mono block">--</span>
|
||||
<span id="licRemainingDays" class="text-[11px] text-slate-500 font-semibold mt-0.5 block">--</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 p-4 rounded-xl border border-slate-200">
|
||||
<span class="text-xs text-slate-500 block font-medium mb-1">Chức Năng Được Cấp:</span>
|
||||
<span id="licTitle" class="text-sm font-bold text-slate-800 block">Sử dụng Web giao ban</span>
|
||||
<span class="text-[11px] text-slate-400 font-mono mt-0.5 block">Key: <b id="licKey" class="text-slate-600">web_giao_ban</b></span>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 p-4 rounded-xl border border-slate-200">
|
||||
<span class="text-xs text-slate-500 block font-medium mb-1">Đối Tác & Ghi Chú:</span>
|
||||
<span id="licDoiTac" class="text-sm font-bold text-slate-800 block">--</span>
|
||||
<span id="licGhiChu" class="text-[11px] text-slate-500 mt-0.5 block italic">--</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="licenseAlertBox" class="mt-4 p-3.5 rounded-xl border text-xs flex items-center gap-2.5 bg-blue-50 border-blue-200 text-blue-800">
|
||||
<i class="fa-solid fa-circle-info text-base"></i>
|
||||
<span id="licenseAlertMsg">Đang tải trạng thái kích hoạt bản quyền...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- License Activation Form -->
|
||||
<div class="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-key text-indigo-600"></i>
|
||||
<span>Nhập & Kích Hoạt Key Bản Quyền</span>
|
||||
</h2>
|
||||
<span class="text-xs text-slate-500 font-medium">Quy tắc chuẩn OTH.Adapter PrivateCode</span>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-xl p-3.5 text-xs text-amber-800 flex items-start gap-2.5">
|
||||
<i class="fa-solid fa-lightbulb text-amber-600 text-base mt-0.5"></i>
|
||||
<div class="leading-relaxed">
|
||||
<b>Hướng dẫn:</b> Quý khách chỉ dán chuỗi mã hóa bản quyền (phần ciphertext bắt đầu bằng <code class="bg-amber-100 px-1 py-0.5 rounded font-mono font-bold">U2FsdGVkX1...</code>). Hệ thống sẽ tự động giải mã bằng thuật toán OpenSSL AES-256-CBC, xác thực mã đơn vị, chức năng và kiểm tra thời hạn sử dụng trước khi kích hoạt.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-700 text-xs mb-1.5 flex items-center justify-between">
|
||||
<span>Chuỗi Key Bản Quyền (DECODE Token):</span>
|
||||
<span class="text-slate-400 font-normal">Định dạng Base64 Salted__</span>
|
||||
</label>
|
||||
<textarea id="txtLicenseKey" rows="4" class="w-full rounded-xl border border-slate-300 p-3 bg-slate-50 font-mono text-xs outline-none focus:ring-2 focus:ring-indigo-500 text-slate-700 leading-relaxed resize-y" placeholder="Dán chuỗi token bản quyền (bắt đầu bằng U2FsdGVkX1...) vào đây..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<button id="btnPasteSampleKey" type="button" class="text-xs text-indigo-600 hover:text-indigo-800 font-semibold flex items-center gap-1.5 py-2">
|
||||
<i class="fa-solid fa-clipboard-check"></i>
|
||||
<span>Dán Key Mẫu (Trà Cú)</span>
|
||||
</button>
|
||||
|
||||
<button id="btnActivateLicense" type="button" class="bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-700 hover:to-blue-700 text-white text-xs font-bold px-6 py-2.5 rounded-xl shadow-md flex items-center gap-2 transition-all">
|
||||
<i class="fa-solid fa-check-double"></i>
|
||||
<span>Kích Hoạt Key Bản Quyền</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- TOAST -->
|
||||
|
||||
+117
@@ -16,7 +16,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
checkUpdate();
|
||||
} else if (hash === '#tab-logs') {
|
||||
switchTab('tab-logs');
|
||||
} else if (hash === '#tab-license') {
|
||||
switchTab('tab-license');
|
||||
}
|
||||
|
||||
initLicenseManager();
|
||||
});
|
||||
|
||||
function showToast(msg) {
|
||||
@@ -446,3 +450,116 @@ function escapeHtml(str) {
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// LICENSE MANAGEMENT
|
||||
// -------------------------------------------------------------------
|
||||
const SAMPLE_LICENSE_KEY = "U2FsdGVkX18Zsq52FPbB7FS1QM7CwWh+t1Esi96gUMFILJD0vZXM69uQBC4sP7TjLVOiJPZt0KIq17iql+hXehLCVSbCEhwKKm+zNab+u2Swah63A52hz0KMJXddLRZ6vanmP2goLTzY7749HsFVRDq+3bdbi8fdUZCpAsSTEiqC2cRTpF5WVh0SHt1wX/i42YSQgOYPeXQtg+lRx/URk4p7lYSVQOYWXglp99kO+Va1ADoNcTgrfz3aaAxAkj0S";
|
||||
|
||||
function initLicenseManager() {
|
||||
loadLicenseStatus();
|
||||
|
||||
const btnPaste = document.getElementById('btnPasteSampleKey');
|
||||
if (btnPaste) {
|
||||
btnPaste.addEventListener('click', () => {
|
||||
const txt = document.getElementById('txtLicenseKey');
|
||||
if (txt) {
|
||||
txt.value = SAMPLE_LICENSE_KEY;
|
||||
showToast('Đã dán mã bản quyền mẫu');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const btnActivate = document.getElementById('btnActivateLicense');
|
||||
if (btnActivate) {
|
||||
btnActivate.addEventListener('click', activateLicense);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLicenseStatus() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/license/status`);
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) {
|
||||
renderLicenseInfo(json.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Lỗi lấy thông tin bản quyền:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLicenseInfo(lic) {
|
||||
const badge = document.getElementById('licenseStatusBadge');
|
||||
const alertBox = document.getElementById('licenseAlertBox');
|
||||
const alertMsg = document.getElementById('licenseAlertMsg');
|
||||
|
||||
document.getElementById('licTenBV').innerText = lic.TenBV || 'Chưa xác định';
|
||||
document.getElementById('licMabvbh').innerText = lic.mabvbh || '--';
|
||||
document.getElementById('licDateExpires').innerText = lic.DateExpires || '--';
|
||||
document.getElementById('licTitle').innerText = lic.title || 'Sử dụng Web giao ban';
|
||||
document.getElementById('licKey').innerText = lic.key || 'web_giao_ban';
|
||||
document.getElementById('licDoiTac').innerText = lic['Đối tác'] || '--';
|
||||
document.getElementById('licGhiChu').innerText = lic['Ghi chú'] || 'Không có';
|
||||
|
||||
if (lic.is_valid) {
|
||||
badge.className = 'text-xs px-2.5 py-1 rounded-full font-bold bg-emerald-100 text-emerald-800 border border-emerald-300 flex items-center gap-1.5';
|
||||
badge.innerHTML = '<i class="fa-solid fa-circle-check text-emerald-600"></i> Hợp Lệ';
|
||||
|
||||
document.getElementById('licRemainingDays').innerHTML = `<span class="text-emerald-600 font-bold"><i class="fa-solid fa-clock"></i> Còn ${lic.remaining_days} ngày</span>`;
|
||||
|
||||
alertBox.className = 'mt-4 p-3.5 rounded-xl border text-xs flex items-center gap-2.5 bg-emerald-50 border-emerald-200 text-emerald-800';
|
||||
alertBox.innerHTML = `<i class="fa-solid fa-circle-check text-emerald-600 text-base"></i><span>${escapeHtml(lic.status_message || 'Bản quyền đang hoạt động bình thường.')}</span>`;
|
||||
} else {
|
||||
badge.className = 'text-xs px-2.5 py-1 rounded-full font-bold bg-rose-100 text-rose-800 border border-rose-300 flex items-center gap-1.5';
|
||||
badge.innerHTML = '<i class="fa-solid fa-triangle-exclamation text-rose-600"></i> Chưa Hợp Lệ';
|
||||
|
||||
document.getElementById('licRemainingDays').innerHTML = `<span class="text-rose-600 font-bold"><i class="fa-solid fa-triangle-exclamation"></i> Không khả dụng</span>`;
|
||||
|
||||
alertBox.className = 'mt-4 p-3.5 rounded-xl border text-xs flex items-center gap-2.5 bg-rose-50 border-rose-200 text-rose-800';
|
||||
alertBox.innerHTML = `<i class="fa-solid fa-circle-xmark text-rose-600 text-base"></i><span>${escapeHtml(lic.status_message || 'Chưa cập nhật key bản quyền hợp lệ.')}</span>`;
|
||||
}
|
||||
|
||||
// Pre-fill textarea if key exists and textarea is empty
|
||||
const txt = document.getElementById('txtLicenseKey');
|
||||
if (txt && !txt.value && lic.raw_encrypted) {
|
||||
txt.value = lic.raw_encrypted;
|
||||
}
|
||||
}
|
||||
|
||||
async function activateLicense() {
|
||||
const txt = document.getElementById('txtLicenseKey');
|
||||
if (!txt) return;
|
||||
|
||||
const keyVal = txt.value.trim();
|
||||
if (!keyVal) {
|
||||
alert('Vui lòng nhập chuỗi key bản quyền (bắt đầu bằng U2FsdGVkX1...)');
|
||||
txt.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btnActivateLicense');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-1"></i> Đang xác thực...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/license/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: keyVal })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) {
|
||||
showToast(json.message);
|
||||
renderLicenseInfo(json.data);
|
||||
alert('Kích hoạt thành công!\n\n' + json.message + '\nĐơn vị: ' + json.data.TenBV + '\nHạn sử dụng: ' + json.data.DateExpires);
|
||||
} else {
|
||||
alert('Kích hoạt không thành công: ' + (json.message || 'Lỗi không xác định'));
|
||||
loadLicenseStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Lỗi kết nối máy chủ: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fa-solid fa-check-double"></i> <span>Kích Hoạt Key Bản Quyền</span>';
|
||||
}
|
||||
}
|
||||
|
||||
+767
-2
@@ -3,6 +3,8 @@ const API_BASE = window.location.pathname.startsWith('/web-giao-ban-go') ? '/web
|
||||
let chartNgoaiTruInstance = null;
|
||||
let chartNoiTruInstance = null;
|
||||
let currentChartTypeNgoaiTru = 'khoa';
|
||||
let currentChartTypeNoiTru = 'noitru-col';
|
||||
let isBienBanLocked = false;
|
||||
|
||||
let rawNgoaiTruData = null;
|
||||
let rawNoiTruData = null;
|
||||
@@ -17,6 +19,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
initTabs();
|
||||
initEvents();
|
||||
initIssueFeatures();
|
||||
initBienBan();
|
||||
await checkLicenseStatus();
|
||||
await loadDatabases();
|
||||
loadData();
|
||||
loadIssues(false);
|
||||
@@ -81,9 +85,38 @@ function initPresets() {
|
||||
const quickPreset = document.getElementById('quickPreset');
|
||||
const dateFrom = document.getElementById('dateFrom');
|
||||
const dateTo = document.getElementById('dateTo');
|
||||
const lockStatusFrom = document.getElementById('lockStatusFrom');
|
||||
const lockStatusTo = document.getElementById('lockStatusTo');
|
||||
|
||||
const quickComparePreset = document.getElementById('quickComparePreset');
|
||||
const compareFrom = document.getElementById('compareFrom');
|
||||
const compareTo = document.getElementById('compareTo');
|
||||
const chkHideZeroRows = document.getElementById('chkHideZeroRows');
|
||||
|
||||
function updateLockState() {
|
||||
const isCustom = quickPreset.value === 'custom';
|
||||
dateFrom.readOnly = !isCustom;
|
||||
dateTo.readOnly = !isCustom;
|
||||
if (isCustom) {
|
||||
dateFrom.classList.remove('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
dateFrom.classList.add('bg-white', 'text-slate-800');
|
||||
dateTo.classList.remove('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
dateTo.classList.add('bg-white', 'text-slate-800');
|
||||
if (lockStatusFrom) lockStatusFrom.innerHTML = '<i class="fa-solid fa-lock-open text-emerald-500 text-[10px]"></i> Mở khóa';
|
||||
if (lockStatusTo) lockStatusTo.innerHTML = '<i class="fa-solid fa-lock-open text-emerald-500 text-[10px]"></i> Mở khóa';
|
||||
} else {
|
||||
dateFrom.classList.add('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
dateFrom.classList.remove('bg-white', 'text-slate-800');
|
||||
dateTo.classList.add('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
dateTo.classList.remove('bg-white', 'text-slate-800');
|
||||
if (lockStatusFrom) lockStatusFrom.innerHTML = '<i class="fa-solid fa-lock text-slate-400 text-[10px]"></i> Cố định';
|
||||
if (lockStatusTo) lockStatusTo.innerHTML = '<i class="fa-solid fa-lock text-slate-400 text-[10px]"></i> Cố định';
|
||||
}
|
||||
}
|
||||
|
||||
quickPreset.addEventListener('change', () => {
|
||||
const val = quickPreset.value;
|
||||
updateLockState();
|
||||
let f = '', t = '';
|
||||
|
||||
switch (val) {
|
||||
@@ -123,13 +156,94 @@ function initPresets() {
|
||||
f = '2026-01-01';
|
||||
t = '2026-12-31';
|
||||
break;
|
||||
case 'custom':
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
dateFrom.value = f;
|
||||
dateTo.value = t;
|
||||
|
||||
// Auto-update compare preset if relative
|
||||
if (quickComparePreset && quickComparePreset.value !== 'custom') {
|
||||
updateComparePresetDates();
|
||||
}
|
||||
loadData();
|
||||
});
|
||||
|
||||
function updateComparePresetDates() {
|
||||
if (!quickComparePreset || !compareFrom || !compareTo) return;
|
||||
const val = quickComparePreset.value;
|
||||
const isCustom = val === 'custom';
|
||||
compareFrom.readOnly = !isCustom;
|
||||
compareTo.readOnly = !isCustom;
|
||||
|
||||
if (isCustom) {
|
||||
compareFrom.classList.remove('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
compareFrom.classList.add('bg-white', 'text-slate-800');
|
||||
compareTo.classList.remove('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
compareTo.classList.add('bg-white', 'text-slate-800');
|
||||
return;
|
||||
}
|
||||
|
||||
compareFrom.classList.add('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
compareFrom.classList.remove('bg-white', 'text-slate-800');
|
||||
compareTo.classList.add('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
compareTo.classList.remove('bg-white', 'text-slate-800');
|
||||
|
||||
if (val === 'month-01-2026') {
|
||||
compareFrom.value = '2026-01-01';
|
||||
compareTo.value = '2026-01-31';
|
||||
} else if (val === 'month-02-2026') {
|
||||
compareFrom.value = '2026-02-01';
|
||||
compareTo.value = '2026-02-28';
|
||||
} else if (val === 'prev-month') {
|
||||
try {
|
||||
const d1 = new Date(dateFrom.value);
|
||||
d1.setMonth(d1.getMonth() - 1);
|
||||
const d2 = new Date(dateTo.value);
|
||||
d2.setMonth(d2.getMonth() - 1);
|
||||
compareFrom.value = d1.toISOString().slice(0, 10);
|
||||
compareTo.value = d2.toISOString().slice(0, 10);
|
||||
} catch (e) {}
|
||||
} else if (val === 'prev-year') {
|
||||
try {
|
||||
const d1 = new Date(dateFrom.value);
|
||||
d1.setFullYear(d1.getFullYear() - 1);
|
||||
const d2 = new Date(dateTo.value);
|
||||
d2.setFullYear(d2.getFullYear() - 1);
|
||||
compareFrom.value = d1.toISOString().slice(0, 10);
|
||||
compareTo.value = d2.toISOString().slice(0, 10);
|
||||
} catch (e) {}
|
||||
} else if (val === 'prev-period') {
|
||||
try {
|
||||
const d1 = new Date(dateFrom.value);
|
||||
const d2 = new Date(dateTo.value);
|
||||
const diff = d2.getTime() - d1.getTime();
|
||||
const prevEnd = new Date(d1.getTime() - 24 * 3600 * 1000);
|
||||
const prevStart = new Date(prevEnd.getTime() - diff);
|
||||
compareFrom.value = prevStart.toISOString().slice(0, 10);
|
||||
compareTo.value = prevEnd.toISOString().slice(0, 10);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (quickComparePreset) {
|
||||
quickComparePreset.addEventListener('change', () => {
|
||||
updateComparePresetDates();
|
||||
loadData();
|
||||
});
|
||||
}
|
||||
|
||||
if (chkHideZeroRows) {
|
||||
chkHideZeroRows.addEventListener('change', () => {
|
||||
renderNgoaiTru();
|
||||
renderNoiTru();
|
||||
});
|
||||
}
|
||||
|
||||
updateLockState();
|
||||
updateComparePresetDates();
|
||||
}
|
||||
|
||||
// Tab Switching
|
||||
@@ -203,6 +317,28 @@ function initEvents() {
|
||||
});
|
||||
});
|
||||
|
||||
// Chart Type toggle for Inpatient
|
||||
document.querySelectorAll('.chart-nt-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.chart-nt-btn').forEach(b => {
|
||||
b.classList.remove('active', 'bg-blue-100', 'text-blue-700', 'font-semibold');
|
||||
b.classList.add('bg-slate-100', 'text-slate-600', 'font-medium');
|
||||
});
|
||||
btn.classList.add('active', 'bg-blue-100', 'text-blue-700', 'font-semibold');
|
||||
btn.classList.remove('bg-slate-100', 'text-slate-600', 'font-medium');
|
||||
currentChartTypeNoiTru = btn.getAttribute('data-ntchart');
|
||||
renderChartNoiTru();
|
||||
});
|
||||
});
|
||||
|
||||
// License Modal close button
|
||||
const btnCloseLicense = document.getElementById('btnCloseLicenseModal');
|
||||
if (btnCloseLicense) {
|
||||
btnCloseLicense.addEventListener('click', () => {
|
||||
document.getElementById('modalLicenseBlock')?.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// Filters for Table 3 & 4
|
||||
document.getElementById('filterKhoaCv').addEventListener('change', (e) => {
|
||||
renderBang3(e.target.value);
|
||||
@@ -341,6 +477,12 @@ async function loadData() {
|
||||
if (body) body.innerHTML = `<tr><td colspan="9" class="text-center py-6 text-rose-500 font-medium"><i class="fa-solid fa-triangle-exclamation mr-1"></i> Không tải được danh sách theo dõi</td></tr>`;
|
||||
}
|
||||
|
||||
// Kiểm tra chặn bản quyền từ backend
|
||||
const licBlocked = [resNt, resNoiTru, resB3, resB4].find(r => r.status === 'fulfilled' && r.value && r.value.license_valid === false);
|
||||
if (licBlocked) {
|
||||
showLicenseBlockModal(licBlocked.value.message);
|
||||
}
|
||||
|
||||
if (hasSuccess) {
|
||||
const selName = document.getElementById('selectDb').selectedOptions[0]?.textContent.split('(')[0].trim() || database;
|
||||
showToast(`Đã nạp báo cáo CSDL: ${selName}`);
|
||||
@@ -357,6 +499,54 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
// Hiển thị Modal thông báo yêu cầu kích hoạt bản quyền
|
||||
function showLicenseBlockModal(msg) {
|
||||
const modal = document.getElementById('modalLicenseBlock');
|
||||
const msgEl = document.getElementById('modalLicenseBlockMsg');
|
||||
if (msgEl && msg) {
|
||||
msgEl.innerHTML = `<i class="fa-solid fa-triangle-exclamation mr-1.5"></i> ${msg}`;
|
||||
}
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Kiểm tra trạng thái bản quyền từ API
|
||||
async function checkLicenseStatus() {
|
||||
const badge = document.getElementById('headerLicenseBadge');
|
||||
const text = document.getElementById('headerLicenseText');
|
||||
const banner = document.getElementById('licenseBanner');
|
||||
const bannerText = document.getElementById('licenseBannerText');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/license/status`);
|
||||
const json = await res.json();
|
||||
if (json.success && json.data) {
|
||||
const d = json.data;
|
||||
if (d.is_valid) {
|
||||
if (badge) {
|
||||
badge.classList.remove('hidden');
|
||||
badge.className = 'text-[11px] px-2.5 py-0.5 rounded-full font-bold bg-emerald-500/20 text-emerald-200 border border-emerald-400/30 flex items-center gap-1.5';
|
||||
if (text) text.innerHTML = `<i class="fa-solid fa-circle-check text-emerald-300"></i> ${d.hospital || d.title || 'Bản quyền hợp lệ'} (HSD: ${d.expires || 'Vô thời hạn'})`;
|
||||
}
|
||||
if (banner) banner.classList.add('hidden');
|
||||
} else {
|
||||
if (badge) {
|
||||
badge.classList.remove('hidden');
|
||||
badge.className = 'text-[11px] px-2.5 py-0.5 rounded-full font-bold bg-rose-500/30 text-rose-200 border border-rose-400/30 flex items-center gap-1.5';
|
||||
if (text) text.innerHTML = `<i class="fa-solid fa-triangle-exclamation text-rose-300"></i> Chưa có bản quyền`;
|
||||
}
|
||||
if (banner) {
|
||||
banner.classList.remove('hidden');
|
||||
if (bannerText) bannerText.innerText = d.reason || 'Hệ thống chưa cập nhật key bản quyền tính năng Web Giao Ban';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Không kiểm tra được bản quyền:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate Khoa Dropdown
|
||||
function populateKhoaDropdown(selectId, dataList) {
|
||||
const sel = document.getElementById(selectId);
|
||||
@@ -420,11 +610,16 @@ function renderNgoaiTru() {
|
||||
if (!cur.khoa || cur.khoa.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-8 text-slate-400">Không có dữ liệu khám chữa bệnh trong khoảng thời gian này</td></tr>';
|
||||
} else {
|
||||
const hideZero = document.getElementById('chkHideZeroRows')?.checked;
|
||||
cur.khoa.forEach(row => {
|
||||
const k1 = parseInt(row.tong_kham || 0, 10);
|
||||
const k2 = parseInt(compMap[row.madv]?.tong_kham || 0, 10);
|
||||
if (hideZero) {
|
||||
if (isCompare && k1 === 0 && k2 === 0) return;
|
||||
if (!isCompare && k1 === 0) return;
|
||||
}
|
||||
let compCols = '';
|
||||
if (isCompare) {
|
||||
const k2 = parseInt(compMap[row.madv]?.tong_kham || 0, 10);
|
||||
const diff = k1 - k2;
|
||||
const pct = k2 > 0 ? ((diff / k2) * 100).toFixed(1) + '%' : (k1 > 0 ? '+100%' : '0%');
|
||||
const diffColor = diff > 0 ? 'text-emerald-600 font-bold' : (diff < 0 ? 'text-rose-600 font-bold' : 'text-slate-500');
|
||||
@@ -532,6 +727,106 @@ function renderChartNgoaiTru() {
|
||||
const khoaList = Array.isArray(cur.khoa) ? cur.khoa : [];
|
||||
const clsList = Array.isArray(cur.cls) ? cur.cls : [];
|
||||
|
||||
// 1. Biểu đồ tròn % khám theo từng khoa (ISS-20261026-002)
|
||||
if (currentChartTypeNgoaiTru === 'khoa-pie') {
|
||||
const top6 = khoaList.slice(0, 6);
|
||||
const otherSum = khoaList.slice(6).reduce((acc, k) => acc + parseInt(k.tong_kham || 0, 10), 0);
|
||||
const labels = top6.map(k => k.tendv);
|
||||
const data = top6.map(k => parseInt(k.tong_kham || 0, 10));
|
||||
if (otherSum > 0) {
|
||||
labels.push('Các khoa khác');
|
||||
data.push(otherSum);
|
||||
}
|
||||
const colors = [
|
||||
'rgba(37, 99, 235, 0.85)',
|
||||
'rgba(16, 185, 129, 0.85)',
|
||||
'rgba(245, 158, 11, 0.85)',
|
||||
'rgba(236, 72, 153, 0.85)',
|
||||
'rgba(99, 102, 241, 0.85)',
|
||||
'rgba(6, 182, 212, 0.85)',
|
||||
'rgba(148, 163, 184, 0.85)'
|
||||
];
|
||||
|
||||
chartNgoaiTruInstance = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: data,
|
||||
backgroundColor: colors.slice(0, labels.length),
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: { font: { size: 11, family: 'Inter, sans-serif' }, boxWidth: 14 }
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const val = context.raw || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(1) : 0;
|
||||
return ` ${context.label}: ${fmt(val)} lượt (${pct}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Biểu đồ cột nhóm BHYT vs Thu phí (ISS-20261026-002)
|
||||
if (currentChartTypeNgoaiTru === 'bhyt-group') {
|
||||
const topKhoa = khoaList.slice(0, 7);
|
||||
const labels = topKhoa.map(k => k.tendv);
|
||||
const dataBhyt = topKhoa.map(k => parseInt(k.bhyt || 0, 10));
|
||||
const dataThuPhi = topKhoa.map(k => parseInt(k.thuphi || 0, 10));
|
||||
|
||||
chartNgoaiTruInstance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Khám BHYT',
|
||||
data: dataBhyt,
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.85)',
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
label: 'Khám Thu Phí / Viện Phí',
|
||||
data: dataThuPhi,
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.85)',
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: { font: { size: 11, family: 'Inter, sans-serif' } }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, ticks: { font: { size: 10 } } },
|
||||
x: { ticks: { font: { size: 10 }, maxRotation: 25, minRotation: 0 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Cột Khoa hoặc CLS thông thường
|
||||
let labels = [];
|
||||
let data1 = [];
|
||||
let data2 = [];
|
||||
@@ -624,6 +919,7 @@ function renderNoiTru() {
|
||||
if (khoaList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="12" class="text-center py-8 text-slate-400">Không có dữ liệu nội trú trong khoảng thời gian này</td></tr>';
|
||||
} else {
|
||||
const hideZero = document.getElementById('chkHideZeroRows')?.checked;
|
||||
khoaList.forEach(row => {
|
||||
const cu = parseInt(row.benh_cu || 0, 10);
|
||||
const moi = parseInt(row.benh_moi || 0, 10);
|
||||
@@ -640,6 +936,11 @@ function renderNoiTru() {
|
||||
totalTron += tron; totalCv += cv; totalTv += tv;
|
||||
totalHienCon += hiencon; totalTd += td; totalCs1 += cs1;
|
||||
|
||||
if (hideZero) {
|
||||
const rowSum = cu + moi + on + nang + tron + cv + tv + hiencon + td + cs1;
|
||||
if (rowSum === 0) return;
|
||||
}
|
||||
|
||||
tbody.innerHTML += `
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="py-2 px-3 font-mono text-slate-500">${row.madv}</td>
|
||||
@@ -690,7 +991,7 @@ function renderNoiTru() {
|
||||
renderChartNoiTru();
|
||||
}
|
||||
|
||||
// Render Chart Nội Trú
|
||||
// Render Chart Nội Trú (ISS-20261027-001)
|
||||
function renderChartNoiTru() {
|
||||
if (!rawNoiTruData) return;
|
||||
const canvas = document.getElementById('chartNoiTru');
|
||||
@@ -698,6 +999,7 @@ function renderChartNoiTru() {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const cur = rawNoiTruData.current || {};
|
||||
const comp = rawNoiTruData.compare;
|
||||
const subTitle = document.getElementById('subTitleChartNoiTru');
|
||||
|
||||
if (chartNoiTruInstance) {
|
||||
chartNoiTruInstance.destroy();
|
||||
@@ -705,6 +1007,159 @@ function renderChartNoiTru() {
|
||||
}
|
||||
|
||||
const khoaList = Array.isArray(cur.khoa) ? cur.khoa : [];
|
||||
|
||||
// 1. Biểu đồ tròn % 4 tiêu chí toàn viện (Bệnh cũ, Mới, Xuất viện, Hiện còn)
|
||||
if (currentChartTypeNoiTru === 'noitru-pie-tong') {
|
||||
if (subTitle) subTitle.innerText = 'Tỷ lệ cơ cấu 4 tiêu chí luân chuyển bệnh nhân toàn viện';
|
||||
let cu = 0, moi = 0, xuat = 0, hiencon = 0;
|
||||
khoaList.forEach(k => {
|
||||
cu += parseInt(k.benh_cu || 0, 10);
|
||||
moi += parseInt(k.benh_moi || 0, 10);
|
||||
xuat += (
|
||||
parseInt(k.on_xin_ve || 0, 10) +
|
||||
parseInt(k.nang_xin_ve || 0, 10) +
|
||||
parseInt(k.tron_vien || 0, 10) +
|
||||
parseInt(k.chuyen_vien || 0, 10) +
|
||||
parseInt(k.tu_vong || 0, 10)
|
||||
);
|
||||
hiencon += parseInt(k.benh_hien_con || 0, 10);
|
||||
});
|
||||
|
||||
chartNoiTruInstance = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Bệnh cũ đầu kỳ', 'Bệnh mới vào viện', 'Tổng xuất viện', 'Hiện còn điều trị'],
|
||||
datasets: [{
|
||||
data: [cu, moi, xuat, hiencon],
|
||||
backgroundColor: [
|
||||
'rgba(148, 163, 184, 0.85)',
|
||||
'rgba(37, 99, 235, 0.85)',
|
||||
'rgba(16, 185, 129, 0.85)',
|
||||
'rgba(99, 102, 241, 0.85)'
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { font: { size: 11, family: 'Inter, sans-serif' }, boxWidth: 14 } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const val = context.raw || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(1) : 0;
|
||||
return ` ${context.label}: ${fmt(val)} ca (${pct}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Biểu đồ tròn % bệnh hiện còn theo từng khoa
|
||||
if (currentChartTypeNoiTru === 'noitru-pie-khoa') {
|
||||
if (subTitle) subTitle.innerText = '% Phân bố bệnh nhân hiện còn đang điều trị theo từng khoa';
|
||||
const sorted = [...khoaList].sort((a, b) => parseInt(b.benh_hien_con || 0, 10) - parseInt(a.benh_hien_con || 0, 10));
|
||||
const top6 = sorted.slice(0, 6);
|
||||
const otherSum = sorted.slice(6).reduce((acc, k) => acc + parseInt(k.benh_hien_con || 0, 10), 0);
|
||||
const labels = top6.map(k => k.tendv);
|
||||
const data = top6.map(k => parseInt(k.benh_hien_con || 0, 10));
|
||||
if (otherSum > 0) {
|
||||
labels.push('Các khoa khác');
|
||||
data.push(otherSum);
|
||||
}
|
||||
const colors = [
|
||||
'rgba(99, 102, 241, 0.85)',
|
||||
'rgba(37, 99, 235, 0.85)',
|
||||
'rgba(16, 185, 129, 0.85)',
|
||||
'rgba(245, 158, 11, 0.85)',
|
||||
'rgba(236, 72, 153, 0.85)',
|
||||
'rgba(6, 182, 212, 0.85)',
|
||||
'rgba(148, 163, 184, 0.85)'
|
||||
];
|
||||
|
||||
chartNoiTruInstance = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: data,
|
||||
backgroundColor: colors.slice(0, labels.length),
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { font: { size: 11, family: 'Inter, sans-serif' }, boxWidth: 14 } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const val = context.raw || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(1) : 0;
|
||||
return ` ${context.label}: ${fmt(val)} người (${pct}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Biểu đồ cột nhóm 4 tiêu chí theo khoa (Bệnh cũ, Bệnh mới, Xuất viện, Hiện còn)
|
||||
if (currentChartTypeNoiTru === 'noitru-group') {
|
||||
if (subTitle) subTitle.innerText = 'Cột nhóm đối chiếu 4 tiêu chí chi tiết theo từng khoa';
|
||||
const topKhoa = khoaList.slice(0, 6);
|
||||
const labels = topKhoa.map(k => k.tendv);
|
||||
const dataCu = topKhoa.map(k => parseInt(k.benh_cu || 0, 10));
|
||||
const dataMoi = topKhoa.map(k => parseInt(k.benh_moi || 0, 10));
|
||||
const dataXuat = topKhoa.map(k => (
|
||||
parseInt(k.on_xin_ve || 0, 10) +
|
||||
parseInt(k.nang_xin_ve || 0, 10) +
|
||||
parseInt(k.tron_vien || 0, 10) +
|
||||
parseInt(k.chuyen_vien || 0, 10) +
|
||||
parseInt(k.tu_vong || 0, 10)
|
||||
));
|
||||
const dataHienCon = topKhoa.map(k => parseInt(k.benh_hien_con || 0, 10));
|
||||
|
||||
chartNoiTruInstance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{ label: 'Bệnh cũ', data: dataCu, backgroundColor: 'rgba(148, 163, 184, 0.85)', borderRadius: 3 },
|
||||
{ label: 'Bệnh mới', data: dataMoi, backgroundColor: 'rgba(37, 99, 235, 0.85)', borderRadius: 3 },
|
||||
{ label: 'Xuất viện', data: dataXuat, backgroundColor: 'rgba(16, 185, 129, 0.85)', borderRadius: 3 },
|
||||
{ label: 'Hiện còn', data: dataHienCon, backgroundColor: 'rgba(99, 102, 241, 0.85)', borderRadius: 3 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top', labels: { font: { size: 11, family: 'Inter, sans-serif' } } }
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true },
|
||||
x: { ticks: { maxRotation: 25, minRotation: 0, font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Mặc định: Cột vào viện & ra viện (noitru-col)
|
||||
if (subTitle) subTitle.innerText = 'Phân bố lưu lượng và cơ cấu bệnh nhân điều trị';
|
||||
const topKhoa = khoaList.slice(0, 8);
|
||||
const labels = topKhoa.map(k => k.tendv);
|
||||
const dataMoi = topKhoa.map(k => parseInt(k.benh_moi || 0, 10));
|
||||
@@ -1542,3 +1997,313 @@ function exportIssuesExcel() {
|
||||
document.body.removeChild(a);
|
||||
showToast('Đã xuất file Excel tổng hợp issue thành công!');
|
||||
}
|
||||
|
||||
// ========================================================
|
||||
// BIÊN BẢN GIAO BAN (ISS-20261027-002: MẪU A4 & 3 CẤP PHÂN QUYỀN)
|
||||
// ========================================================
|
||||
function initBienBan() {
|
||||
const roleSelect = document.getElementById('selectBienBanRole');
|
||||
const btnSync = document.getElementById('btnSync24hData');
|
||||
const btnSave = document.getElementById('btnSaveBienBan');
|
||||
const btnLock = document.getElementById('btnToggleLockBienBan');
|
||||
const btnPrint = document.getElementById('btnPrintBienBan');
|
||||
|
||||
if (!roleSelect) return;
|
||||
|
||||
// Restore saved role
|
||||
const savedRole = localStorage.getItem('dh_giaoban_bb_role') || 'cap2';
|
||||
roleSelect.value = savedRole;
|
||||
|
||||
// Check saved lock state
|
||||
isBienBanLocked = localStorage.getItem('dh_giaoban_bb_locked') === 'true';
|
||||
|
||||
// Load saved content
|
||||
loadBienBan();
|
||||
|
||||
// Apply permission
|
||||
applyBienBanPermissions();
|
||||
|
||||
roleSelect.addEventListener('change', () => {
|
||||
localStorage.setItem('dh_giaoban_bb_role', roleSelect.value);
|
||||
applyBienBanPermissions();
|
||||
});
|
||||
|
||||
if (btnSync) {
|
||||
btnSync.addEventListener('click', sync24hData);
|
||||
}
|
||||
|
||||
if (btnSave) {
|
||||
btnSave.addEventListener('click', saveBienBan);
|
||||
}
|
||||
|
||||
if (btnLock) {
|
||||
btnLock.addEventListener('click', toggleLockBienBan);
|
||||
}
|
||||
|
||||
if (btnPrint) {
|
||||
btnPrint.addEventListener('click', () => window.print());
|
||||
}
|
||||
|
||||
// Update hospital name when DB changes
|
||||
const selectDb = document.getElementById('selectDb');
|
||||
if (selectDb) {
|
||||
selectDb.addEventListener('change', () => {
|
||||
const hospitalName = selectDb.selectedOptions[0]?.textContent.split('(')[0].trim() || 'BỆNH VIỆN ĐA KHOA';
|
||||
const bbTenBv = document.getElementById('bbTenBenhVien');
|
||||
if (bbTenBv && (!bbTenBv.value || bbTenBv.value === 'BỆNH VIỆN ĐA KHOA Ô MÔN')) {
|
||||
bbTenBv.value = hospitalName.toUpperCase();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyBienBanPermissions() {
|
||||
const role = document.getElementById('selectBienBanRole')?.value || 'cap2';
|
||||
const badgeLock = document.getElementById('badgeLockStatus');
|
||||
const btnSync = document.getElementById('btnSync24hData');
|
||||
const btnSave = document.getElementById('btnSaveBienBan');
|
||||
const btnLock = document.getElementById('btnToggleLockBienBan');
|
||||
|
||||
// Input fields
|
||||
const generalInputs = [
|
||||
'bbTenBenhVien', 'bbSoBienBan', 'bbNgayThangNam', 'bbThoiGian',
|
||||
'bbDiaDiem', 'bbChuTri', 'bbThuKy', 'bbThanhPhan',
|
||||
'bbNoiDungKcb', 'bbNoiDungChuyenMon', 'bbNoiDungDuocTrangThietBi', 'bbNoiDungYkienKhoa'
|
||||
];
|
||||
const leadershipInput = 'bbNoiDungKetLuan';
|
||||
|
||||
if (badgeLock) {
|
||||
if (isBienBanLocked) {
|
||||
badgeLock.className = 'text-xs px-2.5 py-1 rounded-full font-bold bg-rose-100 text-rose-800 border border-rose-300 flex items-center gap-1';
|
||||
badgeLock.innerHTML = '<i class="fa-solid fa-lock text-rose-600"></i> Đã khóa biên bản (Chỉ xem)';
|
||||
if (btnLock) btnLock.innerHTML = '<i class="fa-solid fa-lock-open mr-1"></i> Mở khóa biên bản';
|
||||
} else {
|
||||
badgeLock.className = 'text-xs px-2.5 py-1 rounded-full font-bold bg-emerald-100 text-emerald-800 border border-emerald-300 flex items-center gap-1';
|
||||
badgeLock.innerHTML = '<i class="fa-solid fa-lock-open text-emerald-600"></i> Đang mở soạn thảo';
|
||||
if (btnLock) btnLock.innerHTML = '<i class="fa-solid fa-lock mr-1"></i> Khóa biên bản';
|
||||
}
|
||||
}
|
||||
|
||||
// Cap 1: Chỉ xem (ReadOnly hết)
|
||||
if (role === 'cap1') {
|
||||
generalInputs.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.readOnly = true;
|
||||
});
|
||||
const elLead = document.getElementById(leadershipInput);
|
||||
if (elLead) elLead.readOnly = true;
|
||||
|
||||
if (btnSync) btnSync.classList.add('hidden');
|
||||
if (btnSave) btnSave.classList.add('hidden');
|
||||
if (btnLock) btnLock.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// If locked, nobody can edit except cap3 who can toggle unlock
|
||||
if (isBienBanLocked) {
|
||||
generalInputs.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.readOnly = true;
|
||||
});
|
||||
const elLead = document.getElementById(leadershipInput);
|
||||
if (elLead) elLead.readOnly = true;
|
||||
|
||||
if (btnSync) btnSync.classList.add('hidden');
|
||||
if (btnSave) btnSave.classList.add('hidden');
|
||||
if (btnLock) {
|
||||
btnLock.classList.toggle('hidden', role !== 'cap3');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Unlocked state:
|
||||
if (btnSync) btnSync.classList.remove('hidden');
|
||||
if (btnSave) btnSave.classList.remove('hidden');
|
||||
|
||||
if (role === 'cap2') {
|
||||
// Thư ký: có thể sửa mục chung, KHÔNG thể sửa mục V (Kết luận BGĐ), KHÔNG được khóa
|
||||
generalInputs.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.readOnly = false;
|
||||
});
|
||||
const elLead = document.getElementById(leadershipInput);
|
||||
if (elLead) {
|
||||
elLead.readOnly = true;
|
||||
elLead.title = 'Chỉ Ban Giám Đốc (Cấp 3) mới có quyền nhập kết luận chỉ đạo.';
|
||||
}
|
||||
if (btnLock) btnLock.classList.add('hidden');
|
||||
} else if (role === 'cap3') {
|
||||
// BGĐ: sửa được tất cả và có nút khóa
|
||||
generalInputs.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.readOnly = false;
|
||||
});
|
||||
const elLead = document.getElementById(leadershipInput);
|
||||
if (elLead) {
|
||||
elLead.readOnly = false;
|
||||
elLead.removeAttribute('title');
|
||||
}
|
||||
if (btnLock) btnLock.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function sync24hData() {
|
||||
const dFrom = document.getElementById('dateFrom')?.value || '';
|
||||
const dTo = document.getElementById('dateTo')?.value || '';
|
||||
const selectDb = document.getElementById('selectDb');
|
||||
const hospitalName = selectDb?.selectedOptions[0]?.textContent.split('(')[0].trim() || 'BỆNH VIỆN';
|
||||
|
||||
// Fill Hospital Name
|
||||
const bbTenBv = document.getElementById('bbTenBenhVien');
|
||||
if (bbTenBv) bbTenBv.value = hospitalName.toUpperCase();
|
||||
|
||||
// Fill Date
|
||||
const bbNgay = document.getElementById('bbNgayThangNam');
|
||||
if (bbNgay) {
|
||||
const today = new Date();
|
||||
bbNgay.value = `Ngày ${today.getDate()} tháng ${today.getMonth() + 1} năm ${today.getFullYear()}`;
|
||||
}
|
||||
|
||||
let textKcb = `TỔNG HỢP HOẠT ĐỘNG KHÁM CHỮA BỆNH (Kỳ báo cáo: ${dFrom} đến ${dTo})\n\n`;
|
||||
|
||||
// 1. Ngoại trú
|
||||
if (rawNgoaiTruData && rawNgoaiTruData.current) {
|
||||
const cur = rawNgoaiTruData.current;
|
||||
let totalKham = 0, totalBhyt = 0, totalThuPhi = 0, totalCv = 0;
|
||||
const khoaList = cur.khoa || [];
|
||||
khoaList.forEach(k => {
|
||||
totalKham += parseInt(k.tong_kham || 0, 10);
|
||||
totalBhyt += parseInt(k.bhyt || 0, 10);
|
||||
totalThuPhi += parseInt(k.thuphi || 0, 10);
|
||||
totalCv += parseInt(k.chuyenvien || 0, 10);
|
||||
});
|
||||
|
||||
textKcb += `1. Khám Bệnh Ngoại Trú:\n`;
|
||||
textKcb += `- Tổng lượt khám: ${fmt(totalKham)} lượt (BHYT: ${fmt(totalBhyt)} lượt, Thu phí: ${fmt(totalThuPhi)} lượt, Chuyển viện: ${fmt(totalCv)} lượt).\n`;
|
||||
|
||||
const topKhoa = khoaList.slice(0, 3).map(k => `${k.tendv} (${fmt(k.tong_kham)})`).join(', ');
|
||||
if (topKhoa) {
|
||||
textKcb += `- Các phòng/khoa khám đông nhất: ${topKhoa}.\n`;
|
||||
}
|
||||
|
||||
if (cur.cls && cur.cls.length > 0) {
|
||||
let xn = 0, cdha = 0;
|
||||
cur.cls.forEach(c => {
|
||||
if (c.kho === 'XN') xn += parseInt(c.so_luot || 0, 10);
|
||||
else cdha += parseInt(c.so_luot || 0, 10);
|
||||
});
|
||||
textKcb += `- Cận lâm sàng: Xét nghiệm ${fmt(xn)} lượt; Chẩn đoán hình ảnh & TDCN: ${fmt(cdha)} lượt.\n`;
|
||||
}
|
||||
textKcb += `\n`;
|
||||
}
|
||||
|
||||
// 2. Nội trú
|
||||
if (rawNoiTruData && rawNoiTruData.current) {
|
||||
const cur = rawNoiTruData.current;
|
||||
let cu = 0, moi = 0, on = 0, nang = 0, tron = 0, cv = 0, tv = 0, hiencon = 0, td = 0, cs1 = 0;
|
||||
(cur.khoa || []).forEach(k => {
|
||||
cu += parseInt(k.benh_cu || 0, 10);
|
||||
moi += parseInt(k.benh_moi || 0, 10);
|
||||
on += parseInt(k.on_xin_ve || 0, 10);
|
||||
nang += parseInt(k.nang_xin_ve || 0, 10);
|
||||
tron += parseInt(k.tron_vien || 0, 10);
|
||||
cv += parseInt(k.chuyen_vien || 0, 10);
|
||||
tv += parseInt(k.tu_vong || 0, 10);
|
||||
hiencon += parseInt(k.benh_hien_con || 0, 10);
|
||||
td += parseInt(k.benh_theo_doi || 0, 10);
|
||||
cs1 += parseInt(k.chamsoc_cap1 || 0, 10);
|
||||
});
|
||||
|
||||
const xuatVien = on + nang + tron + cv + tv;
|
||||
textKcb += `2. Điều Trị Nội Trú:\n`;
|
||||
textKcb += `- Bệnh nhân cũ đầu kỳ: ${fmt(cu)} người.\n`;
|
||||
textKcb += `- Bệnh nhân mới vào viện trong kỳ: ${fmt(moi)} người.\n`;
|
||||
textKcb += `- Tổng xuất viện: ${fmt(xuatVien)} người (Trong đó: Ổn cho về: ${fmt(on)}, Xin về nặng: ${fmt(nang)}, Trốn viện: ${fmt(tron)}, Chuyển tuyến: ${fmt(cv)}, Tử vong: ${fmt(tv)}).\n`;
|
||||
textKcb += `- Hiện còn đang điều trị tại các khoa: ${fmt(hiencon)} người.\n`;
|
||||
textKcb += `- Bệnh nhân nặng cần theo dõi đặc biệt: ${fmt(td)} ca; Chăm sóc Cấp I: ${fmt(cs1)} ca.\n\n`;
|
||||
}
|
||||
|
||||
// 3. Chuyển viện
|
||||
if (rawBang3Data && rawBang3Data.length > 0) {
|
||||
textKcb += `3. Bệnh Nhân Chuyển Tuyến / Chuyển Viện:\n`;
|
||||
textKcb += `- Tổng số: ${rawBang3Data.length} bệnh nhân được làm thủ tục chuyển viện an toàn theo đúng quy trình chuyên môn (Chi tiết đính kèm Bảng 3).\n`;
|
||||
}
|
||||
|
||||
const bbKcbEl = document.getElementById('bbNoiDungKcb');
|
||||
if (bbKcbEl) {
|
||||
bbKcbEl.value = textKcb.trim();
|
||||
}
|
||||
|
||||
showToast('Đã đồng bộ số liệu 24h vào biên bản thành công!');
|
||||
}
|
||||
|
||||
function saveBienBan() {
|
||||
const fields = [
|
||||
'bbTenBenhVien', 'bbSoBienBan', 'bbNgayThangNam', 'bbThoiGian',
|
||||
'bbDiaDiem', 'bbChuTri', 'bbThuKy', 'bbThanhPhan',
|
||||
'bbNoiDungKcb', 'bbNoiDungChuyenMon', 'bbNoiDungDuocTrangThietBi', 'bbNoiDungYkienKhoa', 'bbNoiDungKetLuan'
|
||||
];
|
||||
|
||||
const data = {};
|
||||
fields.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) data[id] = el.value;
|
||||
});
|
||||
|
||||
const selectDb = document.getElementById('selectDb')?.value || 'default';
|
||||
localStorage.setItem(`dh_bienban_data_${selectDb}`, JSON.stringify(data));
|
||||
|
||||
// Sync sign names
|
||||
const signThuKy = document.getElementById('bbSignThuKy');
|
||||
const bbThuKy = document.getElementById('bbThuKy');
|
||||
if (signThuKy && bbThuKy) signThuKy.innerText = bbThuKy.value || 'BS. Trực tổng hợp';
|
||||
|
||||
const signChuTri = document.getElementById('bbSignChuTri');
|
||||
const bbChuTri = document.getElementById('bbChuTri');
|
||||
if (signChuTri && bbChuTri) signChuTri.innerText = bbChuTri.value || 'BS.CKII - Ban Giám Đốc';
|
||||
|
||||
showToast('Đã lưu biên bản giao ban thành công!');
|
||||
}
|
||||
|
||||
function loadBienBan() {
|
||||
const selectDb = document.getElementById('selectDb')?.value || 'default';
|
||||
const raw = localStorage.getItem(`dh_bienban_data_${selectDb}`);
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
Object.keys(data).forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = data[id];
|
||||
});
|
||||
|
||||
const signThuKy = document.getElementById('bbSignThuKy');
|
||||
const bbThuKy = document.getElementById('bbThuKy');
|
||||
if (signThuKy && bbThuKy && bbThuKy.value) signThuKy.innerText = bbThuKy.value;
|
||||
|
||||
const signChuTri = document.getElementById('bbSignChuTri');
|
||||
const bbChuTri = document.getElementById('bbChuTri');
|
||||
if (signChuTri && bbChuTri && bbChuTri.value) signChuTri.innerText = bbChuTri.value;
|
||||
} catch (e) {
|
||||
console.warn('Lỗi đọc biên bản lưu trữ:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLockBienBan() {
|
||||
const role = document.getElementById('selectBienBanRole')?.value;
|
||||
if (role !== 'cap3') {
|
||||
alert('Chỉ Ban Giám Đốc / Chủ trì giao ban (Cấp 3) mới có thẩm quyền Khóa hoặc Mở khóa biên bản!');
|
||||
return;
|
||||
}
|
||||
|
||||
isBienBanLocked = !isBienBanLocked;
|
||||
localStorage.setItem('dh_giaoban_bb_locked', isBienBanLocked ? 'true' : 'false');
|
||||
applyBienBanPermissions();
|
||||
|
||||
if (isBienBanLocked) {
|
||||
saveBienBan();
|
||||
showToast('Biên bản đã được KHÓA và chuyển sang chế độ CHỈ XEM.');
|
||||
} else {
|
||||
showToast('Biên bản đã được MỞ KHÓA để tiếp tục hiệu chỉnh.');
|
||||
}
|
||||
}
|
||||
|
||||
+263
-27
@@ -25,10 +25,13 @@
|
||||
<div>
|
||||
<h1 class="text-xl font-bold tracking-tight uppercase flex items-center gap-2 flex-wrap">
|
||||
Phần Mềm Quản Lý Giao Ban Bệnh Viện
|
||||
<span class="text-xs bg-cyan-500/30 text-cyan-200 border border-cyan-400/40 px-2 py-0.5 rounded-full font-normal">DH-HIS Web</span>
|
||||
<span class="text-xs bg-cyan-500/30 text-cyan-200 border border-cyan-400/40 px-2 py-0.5 rounded-full font-normal">DH-HIS Web (Go)</span>
|
||||
<span id="currentDbBadge" class="text-xs bg-emerald-500/20 text-emerald-300 border border-emerald-400/30 px-2.5 py-0.5 rounded-full font-medium flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span> BVĐK Ô Môn
|
||||
</span>
|
||||
<span id="headerLicenseBadge" class="text-[11px] px-2 py-0.5 rounded-full font-bold bg-amber-500/30 text-amber-200 border border-amber-400/30 hidden">
|
||||
<i class="fa-solid fa-shield-halved"></i> <span id="headerLicenseText">Kiểm tra bản quyền</span>
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-xs text-sky-200">Báo cáo Giao ban Ngoại trú & Nội trú hằng ngày - Phân tích đối chiếu đa chu kỳ</p>
|
||||
</div>
|
||||
@@ -58,6 +61,19 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- LICENSE WARNING BANNER -->
|
||||
<div id="licenseBanner" class="hidden bg-gradient-to-r from-amber-600 to-rose-600 text-white shadow-md">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-2.5 flex flex-wrap items-center justify-between gap-3 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa-solid fa-triangle-exclamation text-amber-200 text-base"></i>
|
||||
<span id="licenseBannerText" class="font-bold">Hệ thống chưa cập nhật key bản quyền tính năng Web Giao Ban</span>
|
||||
</div>
|
||||
<a href="admin.html#tab-license" class="bg-white text-rose-700 font-bold px-3 py-1 rounded-lg shadow-sm hover:bg-rose-50 transition-all flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-key"></i> Kích Hoạt Bản Quyền Ngay
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTROL PANEL / FILTER TOOLBAR -->
|
||||
<section class="max-w-7xl mx-auto px-4 sm:px-6 mt-4">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-4 transition-all">
|
||||
@@ -85,12 +101,18 @@
|
||||
<!-- Kỳ chính: Từ ngày - Đến ngày -->
|
||||
<div class="md:col-span-4 flex items-center gap-2">
|
||||
<div class="w-1/2">
|
||||
<label class="block text-xs font-semibold text-slate-600 mb-1">Từ ngày:</label>
|
||||
<input type="date" id="dateFrom" class="w-full text-sm rounded-lg border border-slate-300 px-2.5 py-1.5 bg-slate-50 outline-none focus:ring-2 focus:ring-blue-500" value="2026-03-01">
|
||||
<label class="block text-xs font-semibold text-slate-600 mb-1 flex items-center justify-between">
|
||||
<span>Từ ngày:</span>
|
||||
<span id="lockStatusFrom" class="text-[10px] text-slate-400 font-normal"><i class="fa-solid fa-lock text-[10px]"></i> Cố định</span>
|
||||
</label>
|
||||
<input type="date" id="dateFrom" class="w-full text-sm rounded-lg border border-slate-300 px-2.5 py-1.5 bg-slate-100 text-slate-500 cursor-not-allowed outline-none font-medium" value="2026-03-01" readonly>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<label class="block text-xs font-semibold text-slate-600 mb-1">Đến ngày:</label>
|
||||
<input type="date" id="dateTo" class="w-full text-sm rounded-lg border border-slate-300 px-2.5 py-1.5 bg-slate-50 outline-none focus:ring-2 focus:ring-blue-500" value="2026-03-31">
|
||||
<label class="block text-xs font-semibold text-slate-600 mb-1 flex items-center justify-between">
|
||||
<span>Đến ngày:</span>
|
||||
<span id="lockStatusTo" class="text-[10px] text-slate-400 font-normal"><i class="fa-solid fa-lock text-[10px]"></i> Cố định</span>
|
||||
</label>
|
||||
<input type="date" id="dateTo" class="w-full text-sm rounded-lg border border-slate-300 px-2.5 py-1.5 bg-slate-100 text-slate-500 cursor-not-allowed outline-none font-medium" value="2026-03-31" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,19 +145,35 @@
|
||||
|
||||
<!-- KỲ SO SÁNH (Hiển thị khi bật enableCompare) -->
|
||||
<div id="compareContainer" class="hidden mt-3 pt-3 border-t border-dashed border-indigo-200 bg-indigo-50/50 p-3 rounded-lg">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<span class="text-xs font-bold text-indigo-900 flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-code-compare text-indigo-600"></i> Kỳ so sánh (Ví dụ: Tháng 01/2026):
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-slate-600 font-medium">Từ:</label>
|
||||
<input type="date" id="compareFrom" class="text-sm rounded border border-indigo-300 px-2 py-1 bg-white outline-none" value="2026-01-01">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="text-xs font-bold text-indigo-900 flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-code-compare text-indigo-600"></i> Kỳ so sánh 2:
|
||||
</span>
|
||||
<!-- Chọn nhanh mốc so sánh (ISS-20261027-003) -->
|
||||
<select id="quickComparePreset" class="text-xs rounded-lg border border-indigo-300 px-2.5 py-1.5 bg-white outline-none font-medium text-slate-700">
|
||||
<option value="month-01-2026" selected>Tháng 01/2026 (Mặc định)</option>
|
||||
<option value="month-02-2026">Tháng 02/2026</option>
|
||||
<option value="prev-period">Kỳ liền trước (khoảng thời gian tương đương)</option>
|
||||
<option value="prev-month">Cùng kỳ tháng trước</option>
|
||||
<option value="prev-year">Cùng kỳ năm trước</option>
|
||||
<option value="custom">Tùy chọn khoảng ngày...</option>
|
||||
</select>
|
||||
<div class="flex items-center gap-1.5 text-xs">
|
||||
<label class="text-slate-600 font-medium">Từ:</label>
|
||||
<input type="date" id="compareFrom" class="text-xs rounded border border-indigo-300 px-2 py-1 bg-slate-100 text-slate-500 cursor-not-allowed outline-none font-medium" value="2026-01-01" readonly>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-xs">
|
||||
<label class="text-slate-600 font-medium">Đến:</label>
|
||||
<input type="date" id="compareTo" class="text-xs rounded border border-indigo-300 px-2 py-1 bg-slate-100 text-slate-500 cursor-not-allowed outline-none font-medium" value="2026-01-31" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-slate-600 font-medium">Đến:</label>
|
||||
<input type="date" id="compareTo" class="text-sm rounded border border-indigo-300 px-2 py-1 bg-white outline-none" value="2026-01-31">
|
||||
</div>
|
||||
<span class="text-xs text-slate-500 italic">* Hệ thống sẽ đối chiếu song song và vẽ biểu đồ cột tăng giảm tự động.</span>
|
||||
|
||||
<!-- Tùy chọn ẩn dòng = 0 (ISS-20261027-004) -->
|
||||
<label class="inline-flex items-center gap-1.5 text-xs font-semibold text-indigo-900 cursor-pointer bg-white border border-indigo-200 px-2.5 py-1 rounded-lg hover:bg-indigo-50 transition-colors shadow-2xs">
|
||||
<input type="checkbox" id="chkHideZeroRows" class="rounded text-indigo-600 focus:ring-indigo-500">
|
||||
<span>Ẩn dòng không có dữ liệu (2 kỳ = 0)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,9 +190,13 @@
|
||||
<i class="fa-solid fa-bed-pulse"></i>
|
||||
<span>2. Báo Cáo Giao Ban Nội Trú</span>
|
||||
</button>
|
||||
<button class="tab-btn px-4 py-2.5 text-sm font-bold border-b-2 border-transparent text-slate-500 hover:text-slate-800 flex items-center gap-2 transition-all" data-tab="tab-bienban">
|
||||
<i class="fa-solid fa-file-signature text-indigo-600"></i>
|
||||
<span>3. Biên Bản Giao Ban</span>
|
||||
</button>
|
||||
<button class="tab-btn px-4 py-2.5 text-sm font-bold border-b-2 border-transparent text-slate-500 hover:text-slate-800 flex items-center gap-2 transition-all" data-tab="tab-issues">
|
||||
<i class="fa-solid fa-clipboard-list text-rose-500"></i>
|
||||
<span>3. Quản Lý Issue & Báo Lỗi</span>
|
||||
<span>4. Quản Lý Issue & Báo Lỗi</span>
|
||||
<span id="badgeIssueCount" class="ml-1 px-1.5 py-0.2 text-[10px] font-bold rounded-full bg-rose-100 text-rose-700 border border-rose-300">0</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -359,8 +401,10 @@
|
||||
<i class="fa-solid fa-chart-column text-indigo-600"></i>
|
||||
<span>Biểu Đồ Cột So Sánh Số Liệu</span>
|
||||
</h2>
|
||||
<div class="flex gap-1 text-xs">
|
||||
<button class="chart-type-btn active px-2.5 py-1 rounded bg-blue-100 text-blue-700 font-semibold" data-chart="khoa">Theo Khoa</button>
|
||||
<div class="flex gap-1 text-xs flex-wrap">
|
||||
<button class="chart-type-btn active px-2.5 py-1 rounded bg-blue-100 text-blue-700 font-semibold" data-chart="khoa">Cột Khoa</button>
|
||||
<button class="chart-type-btn px-2.5 py-1 rounded bg-slate-100 text-slate-600 font-medium" data-chart="khoa-pie"><i class="fa-solid fa-chart-pie mr-1"></i>% Tròn Khoa</button>
|
||||
<button class="chart-type-btn px-2.5 py-1 rounded bg-slate-100 text-slate-600 font-medium" data-chart="bhyt-group"><i class="fa-solid fa-chart-bar mr-1"></i>BHYT / Thu phí</button>
|
||||
<button class="chart-type-btn px-2.5 py-1 rounded bg-slate-100 text-slate-600 font-medium" data-chart="cls">Theo CLS</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -475,12 +519,20 @@
|
||||
|
||||
<!-- BIỂU ĐỒ CỘT SO SÁNH NỘI TRÚ -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-4">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 pb-2 mb-3">
|
||||
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<i class="fa-solid fa-chart-column text-indigo-600"></i>
|
||||
<span>Biểu Đồ Lưu Lượng Bệnh Nhân & So Sánh Đa Chu Kỳ</span>
|
||||
</h2>
|
||||
<span class="text-xs text-slate-500">So sánh số bệnh mới nhập viện giữa các khoa</span>
|
||||
<div class="flex flex-wrap items-center justify-between border-b border-slate-100 pb-2 mb-3 gap-2">
|
||||
<div>
|
||||
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||
<i class="fa-solid fa-chart-column text-indigo-600"></i>
|
||||
<span>Biểu Đồ Phân Tích Bệnh Nhân Nội Trú</span>
|
||||
</h2>
|
||||
<span class="text-xs text-slate-500" id="subTitleChartNoiTru">Phân bố lưu lượng và cơ cấu bệnh nhân điều trị</span>
|
||||
</div>
|
||||
<div class="flex gap-1 text-xs flex-wrap">
|
||||
<button class="chart-nt-btn active px-2.5 py-1 rounded bg-blue-100 text-blue-700 font-semibold" data-ntchart="noitru-col">Cột Vào Viện</button>
|
||||
<button class="chart-nt-btn px-2.5 py-1 rounded bg-slate-100 text-slate-600 font-medium" data-ntchart="noitru-pie-tong"><i class="fa-solid fa-chart-pie mr-1"></i>% Tròn 4 Tiêu Chí</button>
|
||||
<button class="chart-nt-btn px-2.5 py-1 rounded bg-slate-100 text-slate-600 font-medium" data-ntchart="noitru-pie-khoa"><i class="fa-solid fa-chart-pie mr-1"></i>% Hiện Còn Theo Khoa</button>
|
||||
<button class="chart-nt-btn px-2.5 py-1 rounded bg-slate-100 text-slate-600 font-medium" data-ntchart="noitru-group">Cột Nhóm Tổng Hợp</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative w-full h-80">
|
||||
<canvas id="chartNoiTru"></canvas>
|
||||
@@ -574,7 +626,159 @@
|
||||
</div>
|
||||
|
||||
<!-- ========================================== -->
|
||||
<!-- TAB 3: QUẢN LÝ ISSUE & BÁO LỖI -->
|
||||
<!-- TAB 3: BIÊN BẢN GIAO BAN (MẪU A4 & PHÂN QUYỀN 3 CẤP) -->
|
||||
<!-- ========================================== -->
|
||||
<div id="tab-bienban" class="tab-content hidden space-y-4">
|
||||
|
||||
<!-- Top Action Toolbar -->
|
||||
<div class="bg-white p-3.5 rounded-xl border border-slate-200 shadow-sm flex flex-wrap items-center justify-between gap-3 print:hidden">
|
||||
|
||||
<!-- Role Switcher -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-bold text-slate-700 flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-user-shield text-indigo-600"></i> Vai trò đăng nhập:
|
||||
</span>
|
||||
<select id="selectBienBanRole" class="text-xs font-bold rounded-lg border border-indigo-300 px-3 py-1.5 bg-indigo-50 text-indigo-900 outline-none">
|
||||
<option value="cap1">Cấp 1: Bác sĩ / Nhân viên (Chỉ xem)</option>
|
||||
<option value="cap2" selected>Cấp 2: Thư ký giao ban / Trưởng khoa (Ghi chép)</option>
|
||||
<option value="cap3">Cấp 3: Chủ trì / Ban Giám đốc (Kết luận & Khóa)</option>
|
||||
</select>
|
||||
<span id="badgeLockStatus" class="text-xs px-2.5 py-1 rounded-full font-bold bg-emerald-100 text-emerald-800 border border-emerald-300 flex items-center gap-1">
|
||||
<i class="fa-solid fa-lock-open text-emerald-600"></i> Đang mở soạn thảo
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button id="btnSync24hData" class="bg-blue-50 hover:bg-blue-100 text-blue-700 border border-blue-300 text-xs font-semibold px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-all" title="Lấy nhanh số liệu KCB 24h điền vào biên bản">
|
||||
<i class="fa-solid fa-arrows-rotate text-blue-600"></i> Đồng bộ số liệu 24h
|
||||
</button>
|
||||
<button id="btnSaveBienBan" class="bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-semibold px-3 py-1.5 rounded-lg shadow-sm flex items-center gap-1.5 transition-all">
|
||||
<i class="fa-solid fa-floppy-disk"></i> Lưu biên bản
|
||||
</button>
|
||||
<button id="btnToggleLockBienBan" class="bg-amber-600 hover:bg-amber-700 text-white text-xs font-semibold px-3 py-1.5 rounded-lg shadow-sm flex items-center gap-1.5 transition-all">
|
||||
<i class="fa-solid fa-lock"></i> Khóa biên bản
|
||||
</button>
|
||||
<button id="btnPrintBienBan" class="bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-semibold px-3.5 py-1.5 rounded-lg shadow-sm flex items-center gap-1.5 transition-all">
|
||||
<i class="fa-solid fa-print"></i> In Biên Bản A4
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printable A4 Paper Document Container -->
|
||||
<div id="bienBanA4Sheet" class="bg-white rounded-xl shadow-lg border border-slate-300 max-w-4xl mx-auto p-8 sm:p-12 text-slate-900 font-serif leading-relaxed print:shadow-none print:border-none print:p-0 print:max-w-none">
|
||||
|
||||
<!-- Header Quốc Hiệu & Đơn Vị -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-4 pb-4 border-b-2 border-slate-900 items-start">
|
||||
<div class="sm:col-span-5 text-center">
|
||||
<p class="text-xs uppercase font-bold tracking-wider text-slate-800" id="bbTenSoYTe">SỞ Y TẾ THÀNH PHỐ</p>
|
||||
<input type="text" id="bbTenBenhVien" class="text-sm uppercase font-black text-center w-full outline-none border-b border-transparent hover:border-slate-300 focus:border-indigo-500 bg-transparent" value="BỆNH VIỆN ĐA KHOA Ô MÔN">
|
||||
<p class="text-[11px] italic text-slate-600">Số: <input type="text" id="bbSoBienBan" class="w-24 text-center font-mono border-b border-dotted border-slate-400 bg-transparent outline-none" value=".../BB-GB"></p>
|
||||
</div>
|
||||
<div class="sm:col-span-7 text-center">
|
||||
<p class="text-xs uppercase font-bold tracking-wider">CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM</p>
|
||||
<p class="text-xs font-bold underline underline-offset-4 tracking-normal pb-1">Độc lập - Tự do - Hạnh phúc</p>
|
||||
<p class="text-xs italic text-slate-600 mt-2">
|
||||
<input type="text" id="bbNgayThangNam" class="text-center w-64 border-b border-dotted border-slate-400 bg-transparent outline-none italic" value="..., ngày ... tháng ... năm 2026">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document Title -->
|
||||
<div class="text-center my-6">
|
||||
<h1 class="text-xl font-black uppercase tracking-wide text-slate-900">BIÊN BẢN HỌP GIAO BAN BỆNH VIỆN</h1>
|
||||
<p class="text-xs italic text-slate-600 mt-1">(V/v Đánh giá công tác khám chữa bệnh 24h & Triển khai nhiệm vụ trọng tâm)</p>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Section -->
|
||||
<div class="space-y-2 text-xs text-slate-800 bg-slate-50 p-4 rounded-lg border border-slate-200 mb-6 font-sans">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="font-bold">1. Thời gian bắt đầu:</span>
|
||||
<input type="text" id="bbThoiGian" class="border-b border-slate-300 bg-transparent px-1 font-medium outline-none w-48" value="07 giờ 30 phút">
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold">2. Địa điểm:</span>
|
||||
<input type="text" id="bbDiaDiem" class="border-b border-slate-300 bg-transparent px-1 font-medium outline-none w-48" value="Hội trường giao ban">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="font-bold">3. Chủ trì cuộc họp:</span>
|
||||
<input type="text" id="bbChuTri" class="border-b border-slate-300 bg-transparent px-1 font-bold text-indigo-900 outline-none w-48" value="BS.CKII - Giám đốc trực">
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold">4. Thư ký ghi biên bản:</span>
|
||||
<input type="text" id="bbThuKy" class="border-b border-slate-300 bg-transparent px-1 font-medium outline-none w-48" value="BS. Trực tổng hợp">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-bold">5. Thành phần tham dự:</span>
|
||||
<input type="text" id="bbThanhPhan" class="w-full border-b border-slate-300 bg-transparent px-1 font-medium outline-none mt-1" value="Ban Giám đốc, Trưởng/Phó các khoa lâm sàng & cận lâm sàng, Phòng Kế hoạch tổng hợp, Điều dưỡng trưởng bệnh viện và các Bác sĩ trực.">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: Tình hình khám chữa bệnh 24h qua -->
|
||||
<div class="space-y-3 mb-6">
|
||||
<h2 class="text-sm font-bold uppercase tracking-wider text-slate-900 flex items-center justify-between border-b border-slate-300 pb-1">
|
||||
<span>I. TÌNH HÌNH HOẠT ĐỘNG KHÁM CHỮA BỆNH 24 GIỜ QUA</span>
|
||||
</h2>
|
||||
<textarea id="bbNoiDungKcb" rows="5" class="w-full p-3 rounded-lg border border-slate-300 font-sans text-xs outline-none focus:ring-1 focus:ring-indigo-500 leading-relaxed bg-white" placeholder="Bấm nút 'Đồng bộ số liệu 24h' để nạp tự động hoặc nhập tóm tắt số liệu khám ngoại trú, nội trú hiện còn, nhập viện mới, chuyển viện..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Báo cáo công tác trực & Chuyên môn khoa phòng -->
|
||||
<div class="space-y-3 mb-6">
|
||||
<h2 class="text-sm font-bold uppercase tracking-wider text-slate-900 border-b border-slate-300 pb-1">
|
||||
II. BÁO CÁO CÔNG TÁC TRỰC & DIỄN BIẾN CHUYÊN MÔN
|
||||
</h2>
|
||||
<textarea id="bbNoiDungChuyenMon" rows="5" class="w-full p-3 rounded-lg border border-slate-300 font-sans text-xs outline-none focus:ring-1 focus:ring-indigo-500 leading-relaxed bg-white" placeholder="- Tình hình cấp cứu, phẫu thuật cấp cứu trong ca trực. - Bệnh nhân nặng, theo dõi đặc biệt (chăm sóc cấp I). - Các trường hợp chuyển viện an toàn, tai biến y khoa (nếu có): Không có diễn biến bất thường."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Công tác Dược, Vật tư & Trang thiết bị y tế -->
|
||||
<div class="space-y-3 mb-6">
|
||||
<h2 class="text-sm font-bold uppercase tracking-wider text-slate-900 border-b border-slate-300 pb-1">
|
||||
III. CÔNG TÁC DƯỢC, VẬT TƯ & TRANG THIẾT BỊ Y TẾ
|
||||
</h2>
|
||||
<textarea id="bbNoiDungDuocTrangThietBi" rows="4" class="w-full p-3 rounded-lg border border-slate-300 font-sans text-xs outline-none focus:ring-1 focus:ring-indigo-500 leading-relaxed bg-white" placeholder="- Cung ứng thuốc thang, dịch truyền, hóa chất xét nghiệm đầy đủ cho các khoa. - Trang thiết bị chẩn đoán hình ảnh (X-quang, Siêu âm, CT) và máy xét nghiệm hoạt động ổn định."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Section 4: Ý kiến thảo luận & Kiến nghị khoa phòng -->
|
||||
<div class="space-y-3 mb-6">
|
||||
<h2 class="text-sm font-bold uppercase tracking-wider text-slate-900 border-b border-slate-300 pb-1">
|
||||
IV. Ý KIẾN THẢO LUẬN & KIẾN NGHỊ CỦA CÁC KHOA PHÒNG
|
||||
</h2>
|
||||
<textarea id="bbNoiDungYkienKhoa" rows="4" class="w-full p-3 rounded-lg border border-slate-300 font-sans text-xs outline-none focus:ring-1 focus:ring-indigo-500 leading-relaxed bg-white" placeholder="- Khoa Khám bệnh: Đề xuất tăng cường bàn khám vào khung giờ cao điểm 7h30 - 9h00. - Khoa Hồi sức cấp cứu: Đề nghị bảo dưỡng định kỳ hệ thống khí y tế trung tâm."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Section 5: Kết luận & Chỉ đạo của Ban Giám Đốc -->
|
||||
<div class="space-y-3 mb-8 bg-amber-50/50 p-4 rounded-xl border border-amber-200">
|
||||
<h2 class="text-sm font-bold uppercase tracking-wider text-indigo-950 border-b border-amber-300 pb-1 flex items-center justify-between">
|
||||
<span>V. KẾT LUẬN & CHỈ ĐẠO CỦA BAN GIÁM ĐỐC (CHỦ TRÌ GIAO BAN)</span>
|
||||
<span class="text-[10px] font-sans bg-amber-200/80 text-amber-900 px-2 py-0.5 rounded font-bold">Phân quyền Cấp 3</span>
|
||||
</h2>
|
||||
<textarea id="bbNoiDungKetLuan" rows="5" class="w-full p-3 rounded-lg border border-amber-300 font-sans text-xs outline-none focus:ring-1 focus:ring-amber-500 leading-relaxed bg-white font-medium" placeholder="Chỉ đạo của Ban Giám đốc: 1. Các khoa lâm sàng tiếp tục theo dõi sát người bệnh nặng, hội chẩn kịp thời. 2. Đảm bảo quy chế kê đơn, bảng kê chi phí BHYT và hồ sơ bệnh án chuẩn hóa. 3. Phòng KHTH phối hợp tổ CNTT duy trì ổn định hệ thống phần mềm và cập nhật số liệu."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Footer Chữ Ký A4 -->
|
||||
<div class="grid grid-cols-2 gap-8 text-center pt-4 font-sans text-xs">
|
||||
<div>
|
||||
<p class="font-bold uppercase tracking-wider text-slate-800">THƯ KÝ GHI BIÊN BẢN</p>
|
||||
<p class="text-[11px] italic text-slate-500 mb-16">(Ký và ghi rõ họ tên)</p>
|
||||
<p id="bbSignThuKy" class="font-bold text-slate-900">BS. Trực tổng hợp</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold uppercase tracking-wider text-slate-800">CHỦ TRÌ GIAO BAN</p>
|
||||
<p class="text-[11px] italic text-slate-500 mb-16">(Ký duyệt và ghi rõ họ tên)</p>
|
||||
<p id="bbSignChuTri" class="font-bold text-indigo-950">BS.CKII - Ban Giám Đốc</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ========================================== -->
|
||||
<!-- TAB 4: QUẢN LÝ ISSUE & BÁO LỖI -->
|
||||
<!-- ========================================== -->
|
||||
<div id="tab-issues" class="tab-content hidden space-y-4">
|
||||
|
||||
@@ -896,6 +1100,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================================================== -->
|
||||
<!-- MODAL 4: CẢNH BÁO CHƯA KÍCH HOẠT BẢN QUYỀN -->
|
||||
<!-- ======================================================== -->
|
||||
<div id="modalLicenseBlock" class="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-sm flex items-center justify-center p-4 hidden">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden border border-rose-200">
|
||||
<div class="bg-gradient-to-r from-rose-600 to-amber-600 p-5 text-white flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-xl bg-white/20 flex items-center justify-center text-2xl">
|
||||
<i class="fa-solid fa-shield-halved"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-base uppercase">Yêu Cầu Bản Quyền Tính Năng</h3>
|
||||
<p class="text-xs text-rose-100">Hệ thống DH-HIS Web Giao Ban</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 space-y-4 text-xs leading-relaxed text-slate-700">
|
||||
<div class="p-3 bg-rose-50 border border-rose-200 rounded-xl text-rose-800 font-medium" id="modalLicenseBlockMsg">
|
||||
Chưa cập nhật key bản quyền hoặc key đã hết hạn sử dụng. Vui lòng liên hệ bộ phận hỗ trợ kỹ thuật để được cấp mã kích hoạt.
|
||||
</div>
|
||||
<p>Để tiếp tục trích xuất số liệu và sử dụng các phân hệ báo cáo giao ban, vui lòng mở trang Quản Trị và dán chuỗi key bản quyền (phần DECODE token do DH cấp).</p>
|
||||
<div class="flex items-center justify-end gap-2 pt-2 border-t border-slate-200">
|
||||
<button type="button" id="btnCloseLicenseModal" class="px-3 py-2 font-semibold text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
Đóng
|
||||
</button>
|
||||
<a href="admin.html#tab-license" class="bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-700 hover:to-blue-700 text-white px-4 py-2 rounded-lg font-bold shadow flex items-center gap-1.5 transition-all">
|
||||
<i class="fa-solid fa-key"></i>
|
||||
<span>Mở Quản Trị Nhập Key</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NOTIFICATION TOAST -->
|
||||
<div id="toast" class="fixed bottom-4 right-4 z-50 bg-slate-900 text-white text-xs px-4 py-2.5 rounded-lg shadow-xl flex items-center gap-2 transform translate-y-20 opacity-0 transition-all duration-300">
|
||||
<i class="fa-solid fa-circle-check text-emerald-400"></i>
|
||||
|
||||
Reference in New Issue
Block a user