From 358cfbfde1bd8066ecb53322e02cd9e7b005aa55 Mon Sep 17 00:00:00 2001 From: ongtrieuhau Date: Tue, 27 Oct 2026 02:04:23 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20T=C3=ADch=20h=E1=BB=A3p=20B=E1=BA=A3n?= =?UTF-8?q?=20quy=E1=BB=81n=20License=20AES-256=20(OTH.Adapter),=20Bi?= =?UTF-8?q?=C3=AAn=20b=E1=BA=A3n=20giao=20ban=20A4,=20Bi=E1=BB=83u=20?= =?UTF-8?q?=C4=91=E1=BB=93=20tr=C3=B2n=20v=C3=A0=20T=C3=A0i=20li=E1=BB=87u?= =?UTF-8?q?=20tri=E1=BB=83n=20khai=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.json | 6 +- config/config.go | 36 + data/issues.json | 144 ++++ docs/HUONG_DAN_CAI_DAT_TRIEN_KHAI_DEMO.md | 298 +++++++++ handlers/admin.go | 70 ++ handlers/api.go | 39 +- license/license.go | 275 ++++++++ license/license_test.go | 32 + main.go | 8 + public/admin.html | 97 +++ public/admin.js | 117 ++++ public/app.js | 769 +++++++++++++++++++++- public/index.html | 290 +++++++- 13 files changed, 2149 insertions(+), 32 deletions(-) create mode 100644 docs/HUONG_DAN_CAI_DAT_TRIEN_KHAI_DEMO.md create mode 100644 license/license.go create mode 100644 license/license_test.go diff --git a/config.json b/config.json index fb27c94..bfa8c69 100644 --- a/config.json +++ b/config.json @@ -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" } } \ No newline at end of file diff --git a/config/config.go b/config/config.go index 5597d9a..c99a906 100644 --- a/config/config.go +++ b/config/config.go @@ -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 { diff --git a/data/issues.json b/data/issues.json index c2204bc..24dbe6b 100644 --- a/data/issues.json +++ b/data/issues.json @@ -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", diff --git a/docs/HUONG_DAN_CAI_DAT_TRIEN_KHAI_DEMO.md b/docs/HUONG_DAN_CAI_DAT_TRIEN_KHAI_DEMO.md new file mode 100644 index 0000000..2241e14 --- /dev/null +++ b/docs/HUONG_DAN_CAI_DAT_TRIEN_KHAI_DEMO.md @@ -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://: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` diff --git a/handlers/admin.go b/handlers/admin.go index b6d9726..0f04e7d 100644 --- a/handlers/admin.go +++ b/handlers/admin.go @@ -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, + }) +} diff --git a/handlers/api.go b/handlers/api.go index 4108633..547ec81 100644 --- a/handlers/api.go +++ b/handlers/api.go @@ -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"` diff --git a/license/license.go b/license/license.go new file mode 100644 index 0000000..fb6a06a --- /dev/null +++ b/license/license.go @@ -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, "" +} diff --git a/license/license_test.go b/license/license_test.go new file mode 100644 index 0000000..f9eeb64 --- /dev/null +++ b/license/license_test.go @@ -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) +} diff --git a/main.go b/main.go index 92da74d..77acbcd 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/public/admin.html b/public/admin.html index 5c30363..a5baa77 100644 --- a/public/admin.html +++ b/public/admin.html @@ -51,6 +51,10 @@ 3. Nhật Ký Hoạt Động (Live Logs) + @@ -332,6 +336,99 @@ + + + + + diff --git a/public/admin.js b/public/admin.js index d24c88d..f9755ae 100644 --- a/public/admin.js +++ b/public/admin.js @@ -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 = ' Hợp Lệ'; + + document.getElementById('licRemainingDays').innerHTML = ` Còn ${lic.remaining_days} ngày`; + + 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 = `${escapeHtml(lic.status_message || 'Bản quyền đang hoạt động bình thường.')}`; + } 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 = ' Chưa Hợp Lệ'; + + document.getElementById('licRemainingDays').innerHTML = ` Không khả dụng`; + + 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 = `${escapeHtml(lic.status_message || 'Chưa cập nhật key bản quyền hợp lệ.')}`; + } + + // 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 = ' Đ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 = ' Kích Hoạt Key Bản Quyền'; + } +} diff --git a/public/app.js b/public/app.js index f7e8fca..03365ad 100644 --- a/public/app.js +++ b/public/app.js @@ -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 = ' Mở khóa'; + if (lockStatusTo) lockStatusTo.innerHTML = ' 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 = ' Cố định'; + if (lockStatusTo) lockStatusTo.innerHTML = ' 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 = ` Không tải được danh sách theo dõi`; } + // 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 = ` ${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 = ` ${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 = ` 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 = 'Không có dữ liệu khám chữa bệnh trong khoảng thời gian này'; } 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 = 'Không có dữ liệu nội trú trong khoảng thời gian này'; } 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 += ` ${row.madv} @@ -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 = ' Đã khóa biên bản (Chỉ xem)'; + if (btnLock) btnLock.innerHTML = ' 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 = ' Đang mở soạn thảo'; + if (btnLock) btnLock.innerHTML = ' 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.'); + } +} diff --git a/public/index.html b/public/index.html index 5b666a8..b4b89ff 100644 --- a/public/index.html +++ b/public/index.html @@ -25,10 +25,13 @@

Phần Mềm Quản Lý Giao Ban Bệnh Viện - DH-HIS Web + DH-HIS Web (Go) BVĐK Ô Môn +

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ỳ

@@ -58,6 +61,19 @@ + + +
@@ -85,12 +101,18 @@
- - + +
- - + +
@@ -123,19 +145,35 @@ @@ -359,8 +401,10 @@ Biểu Đồ Cột So Sánh Số Liệu -
- +
+ + +
@@ -475,12 +519,20 @@
-
-

- - Biểu Đồ Lưu Lượng Bệnh Nhân & So Sánh Đa Chu Kỳ -

- So sánh số bệnh mới nhập viện giữa các khoa +
+
+

+ + Biểu Đồ Phân Tích Bệnh Nhân Nội Trú +

+ Phân bố lưu lượng và cơ cấu bệnh nhân điều trị +
+
+ + + + +
@@ -574,7 +626,159 @@
- + + + + + +
+ + + + +