From f73ac7b5bb657a6c6088ea131167396e2b5063f5 Mon Sep 17 00:00:00 2001 From: ongtrieuhau Date: Mon, 26 Oct 2026 00:48:05 -0700 Subject: [PATCH] feat: DH-HIS Web Giao Ban (Node.js & Express) complete codebase --- .env | 6 + .gitignore | 5 + README.md | 38 + data/issues.json | 28 + db.js | 57 ++ issueService.js | 147 ++++ package-lock.json | 1048 ++++++++++++++++++++++++++ package.json | 19 + public/app.js | 1544 +++++++++++++++++++++++++++++++++++++++ public/favicon.ico | Bin 0 -> 30568 bytes public/index.html | 899 +++++++++++++++++++++++ public/styles.css | 54 ++ restart.sh | 6 + server.js | 533 ++++++++++++++ start.sh | 21 + status.sh | 27 + stop.sh | 32 + uploads/issues/.gitkeep | 0 18 files changed, 4464 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 README.md create mode 100644 data/issues.json create mode 100644 db.js create mode 100644 issueService.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/favicon.ico create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100755 restart.sh create mode 100644 server.js create mode 100755 start.sh create mode 100755 status.sh create mode 100755 stop.sh create mode 100644 uploads/issues/.gitkeep diff --git a/.env b/.env new file mode 100644 index 0000000..603a8c4 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +PORT=3000 +PG_HOST=192.168.50.79 +PG_PORT=5437 +PG_USER=postgres +PG_PASSWORD=123 +PG_DATABASE=an_omon_full_04052026 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc60b1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.log +.app.pid +uploads/issues/* +!uploads/issues/.gitkeep diff --git a/README.md b/README.md new file mode 100644 index 0000000..892efec --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Phần Mềm Quản Lý Giao Ban Hằng Ngày (DH-HIS Web) + +Ứng dụng Node.js + Express + PostgreSQL phục vụ công tác giao ban bệnh viện hằng ngày, hỗ trợ phân tích đối chiếu đa chu kỳ và tương tác nhập liệu ghi chú trực tiếp. + +## 1. Cấu trúc thư mục +``` +dh-giaoban-web/ +├── server.js # API Server Express (REST APIs cho Ngoại trú, Nội trú, Bảng 3 & 4) +├── db.js # Quản lý kết nối PostgreSQL Pool & chuẩn hóa văn bản RTF +├── .env # Cấu hình CSDL PostgreSQL DH37 (Host, Port, User, DB) +├── package.json # Dependencies: express, pg, cors, dotenv +└── public/ + ├── index.html # Giao diện Dashboard giao ban bệnh viện + ├── app.js # Xử lý lọc kỳ, so sánh đa kỳ, vẽ biểu đồ Chart.js & auto-save ghi chú + └── styles.css # Định dạng giao diện & hỗ trợ in ấn (Print CSS) +``` + +## 2. Cách khởi động ứng dụng +```bash +cd /home/othau/dh-coding/dh-giaoban-web +node server.js +``` +Mở trình duyệt truy cập: **http://localhost:3000** (hoặc `http://:3000`). + +## 3. Các tính năng chính +1. **Giao ban Ngoại trú**: + - Thống kê lượt khám BHYT, Thu phí, Chuyển viện theo các khoa (Khám bệnh, Ngoại, Nội, CSSKSS, Nhi, YDCT,...). + - Thống kê Bệnh án ngoại trú (Mới vs Tái khám) và chuyên khoa YDCT (Điện châm, VLTL, Sắc thuốc, Cấy chỉ). + - Tổng hợp số liệu Cận lâm sàng: Xét nghiệm (Huyết học, Sinh hóa, Nước tiểu, Miễn dịch, Đông máu) & CĐHA (Nội soi, Xquang, Siêu âm, CT, ECG). + - Top 10 chẩn đoán bệnh ngoại trú theo mã ICD-10. + - So sánh số liệu giữa 2 khoảng thời gian không liên tục (Ví dụ: Tháng 1 vs Tháng 3/10) và biểu diễn bằng biểu đồ cột. + +2. **Giao ban Nội trú**: + - Tổng hợp lưu lượng bệnh nhân theo các khoa: Bệnh cũ (tồn đầu), Bệnh mới (nhập viện), Xuất viện (Ổn xin về, Nặng xin về, Trốn, Chuyển viện, Tử vong), Bệnh hiện còn, Bệnh theo dõi, Chăm sóc Cấp I. + - Biểu đồ cột phân tích lưu lượng và so sánh đa chu kỳ. + - **Bảng 3 (Danh sách bệnh nhân Chuyển viện)** & **Bảng 4 (Danh sách bệnh nhân Theo dõi & CS Cấp I)**: + - Hiển thị đầy đủ: Họ tên, Địa chỉ, Ngày VV/RV, Khoa, Chẩn đoán chính/phụ, Tóm tắt kết quả CLS. + - **Cột Ghi chú thông minh**: Các khoa tự nhập liệu trực tiếp trên lưới; hệ thống tự động lưu vào CSDL ngay khi chuyển ô. diff --git a/data/issues.json b/data/issues.json new file mode 100644 index 0000000..c2204bc --- /dev/null +++ b/data/issues.json @@ -0,0 +1,28 @@ +[ + { + "id": "ISS-20261025-001", + "title": "Kiểm tra đối chiếu số liệu tháng 03/2026", + "category": "SAI_LECH_SO_LIEU", + "module": "NGOAI_TRU", + "priority": "CAO", + "database": "an_omon_full_04052026", + "databaseName": "BVĐK Ô Môn - Cần Thơ", + "period": "Từ 2026-03-01 đến 2026-03-31", + "reporter": "BS. Trưởng Khoa KHTH", + "department": "Phòng Kế Hoạch Tổng Hợp", + "description": "Số liệu khám chữa bệnh BHYT tại Khoa Khám Bệnh đối chiếu rất tốt, gửi file test tổng hợp.", + "status": "DA_XU_LY", + "handlerNotes": "Đã kiểm tra đối chiếu khớp 100% với HIS Ô Môn.", + "resolvedBy": "Dev HIS", + "resolvedAt": "2026-10-25T10:26:47.059Z", + "createdAt": "2026-10-25T10:26:38.259Z", + "updatedAt": "2026-10-25T10:26:47.059Z", + "attachment": { + "filename": "sample_log_1792923998248-734023.txt", + "originalname": "sample_log.txt", + "size": 32, + "mimetype": "text/plain", + "url": "/uploads/issues/sample_log_1792923998248-734023.txt" + } + } +] \ No newline at end of file diff --git a/db.js b/db.js new file mode 100644 index 0000000..5f80f0e --- /dev/null +++ b/db.js @@ -0,0 +1,57 @@ +const { Pool } = require('pg'); +require('dotenv').config(); + +const pools = {}; + +function getPool(dbName) { + const database = dbName || process.env.PG_DATABASE || 'an_omon_full_04052026'; + if (!pools[database]) { + pools[database] = new Pool({ + host: process.env.PG_HOST || '192.168.50.79', + port: parseInt(process.env.PG_PORT || '5437', 10), + user: process.env.PG_USER || 'postgres', + password: process.env.PG_PASSWORD || '123', + database: database, + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + statement_timeout: 15000, // 15s timeout to prevent hanging queries + }); + + pools[database].on('error', (err) => { + console.error(`Unexpected error on idle client for db ${database}`, err); + }); + } + return pools[database]; +} + +async function query(sql, params = [], dbName = null) { + const pool = getPool(dbName); + const start = Date.now(); + const res = await pool.query(sql, params); + const duration = Date.now() - start; + // console.log(`[SQL ${duration}ms] executed on ${dbName || 'default'}`); + return res; +} + +// Clean RTF string helper +function cleanRtf(text) { + if (!text) return ''; + if (!text.includes('{\\rtf')) return text.trim(); + // Strip RTF control codes + let clean = text + .replace(/\\par[d]?/g, '\n') + .replace(/\\u([0-9]{3,5})\?/g, (match, dec) => String.fromCharCode(dec)) + .replace(/\\'([0-9a-fA-F]{2})/g, (match, hex) => String.fromCharCode(parseInt(hex, 16))) + .replace(/\{[^{}]*\}/g, '') + .replace(/\\[a-zA-Z0-9\-]+/g, '') + .replace(/[\{\}]/g, '') + .trim(); + return clean; +} + +module.exports = { + getPool, + query, + cleanRtf, +}; diff --git a/issueService.js b/issueService.js new file mode 100644 index 0000000..6a8ea1d --- /dev/null +++ b/issueService.js @@ -0,0 +1,147 @@ +const fs = require('fs'); +const path = require('path'); + +const DATA_DIR = path.join(__dirname, 'data'); +const UPLOADS_DIR = path.join(__dirname, 'uploads', 'issues'); +const ISSUES_FILE = path.join(DATA_DIR, 'issues.json'); + +function ensureDirs() { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + if (!fs.existsSync(UPLOADS_DIR)) { + fs.mkdirSync(UPLOADS_DIR, { recursive: true }); + } + if (!fs.existsSync(ISSUES_FILE)) { + fs.writeFileSync(ISSUES_FILE, JSON.stringify([], null, 2), 'utf-8'); + } +} + +function getAllIssues() { + ensureDirs(); + try { + const raw = fs.readFileSync(ISSUES_FILE, 'utf-8'); + const list = JSON.parse(raw); + return Array.isArray(list) ? list : []; + } catch (err) { + console.error('Error reading issues.json:', err); + return []; + } +} + +function saveIssues(issues) { + ensureDirs(); + const tmpFile = `${ISSUES_FILE}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(issues, null, 2), 'utf-8'); + fs.renameSync(tmpFile, ISSUES_FILE); +} + +function generateIssueId() { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const d = String(now.getDate()).padStart(2, '0'); + const prefix = `ISS-${y}${m}${d}`; + + const all = getAllIssues(); + const todayCount = all.filter(item => item.id && item.id.startsWith(prefix)).length + 1; + const seq = String(todayCount).padStart(3, '0'); + return `${prefix}-${seq}`; +} + +function createIssue(data, file) { + ensureDirs(); + const all = getAllIssues(); + const id = generateIssueId(); + const now = new Date().toISOString(); + + let attachment = null; + if (file) { + attachment = { + filename: file.filename, + originalname: file.originalname, + size: file.size, + mimetype: file.mimetype, + url: `/uploads/issues/${file.filename}`, + }; + } + + const newIssue = { + id, + title: (data.title || 'Không có tiêu đề').trim(), + category: data.category || 'SAI_LECH_SO_LIEU', // SAI_LECH_SO_LIEU, LOI_GIAO_DIEN, LOI_KET_NOI, DE_XUAT, YEU_CAU_MOI, KHAC + module: data.module || 'CHUNG', // NGOAI_TRU, NOI_TRU, CHUYEN_VIEN, THEO_DOI, CHUNG + priority: data.priority || 'BINH_THUONG', // THAP, BINH_THUONG, CAO, KHAN_CAP + database: data.database || '', + databaseName: data.databaseName || data.database || '', + period: data.period || '', + reporter: (data.reporter || 'Cán bộ giao ban').trim(), + department: (data.department || '').trim(), + description: (data.description || '').trim(), + status: 'CHO_XU_LY', // CHO_XU_LY, DANG_XU_LY, DA_XU_LY, DONG + handlerNotes: '', + resolvedBy: '', + resolvedAt: null, + createdAt: now, + updatedAt: now, + attachment, + }; + + all.unshift(newIssue); + saveIssues(all); + return newIssue; +} + +function updateIssue(id, updates) { + const all = getAllIssues(); + const idx = all.findIndex(item => item.id === id); + if (idx === -1) { + throw new Error(`Không tìm thấy Issue với mã: ${id}`); + } + + const now = new Date().toISOString(); + const cur = all[idx]; + + const updated = { + ...cur, + ...updates, + updatedAt: now, + }; + + if (updates.status === 'DA_XU_LY' && cur.status !== 'DA_XU_LY') { + updated.resolvedAt = now; + } + + all[idx] = updated; + saveIssues(all); + return updated; +} + +function deleteIssue(id) { + const all = getAllIssues(); + const idx = all.findIndex(item => item.id === id); + if (idx === -1) { + throw new Error(`Không tìm thấy Issue với mã: ${id}`); + } + + const item = all[idx]; + if (item.attachment && item.attachment.filename) { + const fPath = path.join(UPLOADS_DIR, item.attachment.filename); + if (fs.existsSync(fPath)) { + try { fs.unlinkSync(fPath); } catch (e) { /* ignore */ } + } + } + + all.splice(idx, 1); + saveIssues(all); + return true; +} + +module.exports = { + UPLOADS_DIR, + ensureDirs, + getAllIssues, + createIssue, + updateIssue, + deleteIssue, +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9b89ec7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1048 @@ +{ + "name": "dh-giaoban-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dh-giaoban-web", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "cors": "^2.8.6", + "dotenv": "^18.0.9", + "express": "^5.3.0", + "multer": "^2.5.0", + "pg": "^8.23.1" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.9.tgz", + "integrity": "sha512-3fVOW4cB02R066PUSwn8O4TJyz9NckQl7F81blIbaq9jKBm2tSmOKky7ZHIXzGvkVPXCSn6jkpziBPZdGFs8Tw==", + "bin": { + "dotenv": "dist/index.cjs" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/multer": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz", + "integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e74160 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "dh-giaoban-web", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "cors": "^2.8.6", + "dotenv": "^18.0.9", + "express": "^5.3.0", + "multer": "^2.5.0", + "pg": "^8.23.1" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..6be5fc0 --- /dev/null +++ b/public/app.js @@ -0,0 +1,1544 @@ +// DH-HIS Quản Lý Giao Ban Web Client +const API_BASE = window.location.pathname.startsWith('/web-giao-ban') ? '/web-giao-ban' : ''; +let chartNgoaiTruInstance = null; +let chartNoiTruInstance = null; +let currentChartTypeNgoaiTru = 'khoa'; + +let rawNgoaiTruData = null; +let rawNoiTruData = null; +let rawBang3Data = []; +let rawBang4Data = []; +let rawIssuesData = []; +let currentIssueFile = null; +let editingIssueId = null; + +document.addEventListener('DOMContentLoaded', async () => { + initPresets(); + initTabs(); + initEvents(); + initIssueFeatures(); + await loadDatabases(); + loadData(); + loadIssues(false); +}); + +// Format Number with Vietnamese Thousand Separator +function fmt(num) { + if (num === null || num === undefined || isNaN(num)) return '0'; + return Number(num).toLocaleString('vi-VN'); +} + +// Show Toast +function showToast(msg) { + const toast = document.getElementById('toast'); + const toastMsg = document.getElementById('toastMsg'); + if (!toast || !toastMsg) return; + toastMsg.innerText = msg; + toast.classList.remove('translate-y-20', 'opacity-0'); + toast.classList.add('translate-y-0', 'opacity-100'); + setTimeout(() => { + toast.classList.remove('translate-y-0', 'opacity-100'); + toast.classList.add('translate-y-20', 'opacity-0'); + }, 2500); +} + +// Load dynamic databases list +async function loadDatabases() { + const selectDb = document.getElementById('selectDb'); + if (!selectDb) return; + try { + const res = await fetch(`${API_BASE}/api/databases`); + const json = await res.json(); + if (json.success && Array.isArray(json.data) && json.data.length > 0) { + const savedDb = localStorage.getItem('dh_giaoban_db') || selectDb.value || 'an_omon_full_04052026'; + selectDb.innerHTML = ''; + json.data.forEach(db => { + const opt = document.createElement('option'); + opt.value = db.id; + opt.textContent = db.name; + if (db.id === savedDb) opt.selected = true; + selectDb.appendChild(opt); + }); + updateDbBadge(); + } + } catch (err) { + console.warn('Không tải được danh sách CSDL từ server:', err); + } +} + +// Update Database Badge on Header +function updateDbBadge() { + const selectDb = document.getElementById('selectDb'); + const badge = document.getElementById('currentDbBadge'); + if (badge && selectDb && selectDb.selectedOptions[0]) { + const text = selectDb.selectedOptions[0].textContent.split('(')[0].trim(); + badge.innerHTML = ` ${text}`; + } +} + +// Preset Handler +function initPresets() { + const quickPreset = document.getElementById('quickPreset'); + const dateFrom = document.getElementById('dateFrom'); + const dateTo = document.getElementById('dateTo'); + + quickPreset.addEventListener('change', () => { + const val = quickPreset.value; + let f = '', t = ''; + + switch (val) { + case 'month-03-2026': + f = '2026-03-01'; + t = '2026-03-31'; + break; + case 'month-04-2026': + f = '2026-04-01'; + t = '2026-04-30'; + break; + case 'month-01-2026': + f = '2026-01-01'; + t = '2026-01-31'; + break; + case 'month-02-2026': + f = '2026-02-01'; + t = '2026-02-28'; + break; + case 'today': + f = '2026-03-31'; + t = '2026-03-31'; + break; + case 'yesterday': + f = '2026-03-30'; + t = '2026-03-30'; + break; + case 'this-month': + f = '2026-03-01'; + t = '2026-03-31'; + break; + case 'quarter-1': + f = '2026-01-01'; + t = '2026-03-31'; + break; + case 'year-2026': + f = '2026-01-01'; + t = '2026-12-31'; + break; + default: + return; + } + dateFrom.value = f; + dateTo.value = t; + loadData(); + }); +} + +// Tab Switching +function initTabs() { + const tabBtns = document.querySelectorAll('.tab-btn'); + const tabContents = document.querySelectorAll('.tab-content'); + + tabBtns.forEach(btn => { + btn.addEventListener('click', () => { + tabBtns.forEach(b => { + b.classList.remove('active', 'border-blue-600', 'text-blue-600'); + b.classList.add('border-transparent', 'text-slate-500'); + }); + btn.classList.add('active', 'border-blue-600', 'text-blue-600'); + btn.classList.remove('border-transparent', 'text-slate-500'); + + const target = btn.getAttribute('data-tab'); + tabContents.forEach(c => { + c.classList.toggle('hidden', c.id !== target); + }); + + // Resize charts or load issues + if (target === 'tab-ngoaitru' && chartNgoaiTruInstance) { + chartNgoaiTruInstance.resize(); + } else if (target === 'tab-noitru' && chartNoiTruInstance) { + chartNoiTruInstance.resize(); + } else if (target === 'tab-issues') { + loadIssues(true); + } + }); + }); +} + +// Events +function initEvents() { + const enableCompare = document.getElementById('enableCompare'); + const compareContainer = document.getElementById('compareContainer'); + const btnFilter = document.getElementById('btnFilter'); + const selectDb = document.getElementById('selectDb'); + const btnPrint = document.getElementById('btnPrint'); + const btnExport = document.getElementById('btnExport'); + + enableCompare.addEventListener('change', () => { + compareContainer.classList.toggle('hidden', !enableCompare.checked); + document.querySelectorAll('.th-compare').forEach(el => { + el.classList.toggle('hidden', !enableCompare.checked); + }); + loadData(); + }); + + btnFilter.addEventListener('click', () => loadData()); + selectDb.addEventListener('change', () => { + updateDbBadge(); + loadData(); + }); + + btnPrint.addEventListener('click', () => window.print()); + btnExport.addEventListener('click', () => exportExcel()); + + // Chart Type toggle for Outpatient + document.querySelectorAll('.chart-type-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.chart-type-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'); + currentChartTypeNgoaiTru = btn.getAttribute('data-chart'); + renderChartNgoaiTru(); + }); + }); + + // Filters for Table 3 & 4 + document.getElementById('filterKhoaCv').addEventListener('change', (e) => { + renderBang3(e.target.value); + }); + document.getElementById('filterKhoaTd').addEventListener('change', (e) => { + renderBang4(e.target.value); + }); +} + +// Get Request Params +function getParams() { + const database = document.getElementById('selectDb').value; + const from = document.getElementById('dateFrom').value + ' 00:00:00'; + const to = document.getElementById('dateTo').value + ' 23:59:59'; + const isCompare = document.getElementById('enableCompare').checked; + + let urlParams = `database=${database}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`; + if (isCompare) { + const compareFrom = document.getElementById('compareFrom').value + ' 00:00:00'; + const compareTo = document.getElementById('compareTo').value + ' 23:59:59'; + urlParams += `&compareFrom=${encodeURIComponent(compareFrom)}&compareTo=${encodeURIComponent(compareTo)}`; + } + return { database, from, to, isCompare, urlParams }; +} + +// Set Loading State +function setLoading(isLoading) { + const btnFilter = document.getElementById('btnFilter'); + const selectDb = document.getElementById('selectDb'); + const quickPreset = document.getElementById('quickPreset'); + + if (isLoading) { + if (btnFilter) { + btnFilter.disabled = true; + btnFilter.innerHTML = ' Đang truy vấn...'; + btnFilter.classList.add('opacity-75', 'cursor-not-allowed'); + } + if (selectDb) selectDb.disabled = true; + if (quickPreset) quickPreset.disabled = true; + + // Show skeletons/loading on tables + const bodyKhoa = document.getElementById('bodyKhoaKcb'); + if (bodyKhoa) bodyKhoa.innerHTML = ' Đang tải số liệu khám chữa bệnh...'; + + const bodyCls = document.getElementById('bodyCls'); + if (bodyCls) bodyCls.innerHTML = ' Đang tải Cận lâm sàng...'; + + const bodyTop10 = document.getElementById('bodyTop10'); + if (bodyTop10) bodyTop10.innerHTML = ' Đang tải Top 10 bệnh...'; + + const bodyNoiTru = document.getElementById('bodyNoiTru'); + if (bodyNoiTru) bodyNoiTru.innerHTML = ' Đang tải số liệu nội trú...'; + + const bodyBang3 = document.getElementById('bodyBang3'); + if (bodyBang3) bodyBang3.innerHTML = ' Đang tải danh sách chuyển viện...'; + + const bodyBang4 = document.getElementById('bodyBang4'); + if (bodyBang4) bodyBang4.innerHTML = ' Đang tải danh sách theo dõi & CS Cấp I...'; + } else { + if (btnFilter) { + btnFilter.disabled = false; + btnFilter.innerHTML = ' Xem Báo Cáo'; + btnFilter.classList.remove('opacity-75', 'cursor-not-allowed'); + } + if (selectDb) selectDb.disabled = false; + if (quickPreset) quickPreset.disabled = false; + } +} + +// Master Load Data +async function loadData() { + const { urlParams, database, from, to } = getParams(); + localStorage.setItem('dh_giaoban_db', database); + updateDbBadge(); + + setLoading(true); + + try { + const [resNt, resNoiTru, resB3, resB4] = await Promise.allSettled([ + fetch(`${API_BASE}/api/giao-ban-ngoai-tru?${urlParams}`).then(r => r.json()), + fetch(`${API_BASE}/api/giao-ban-noi-tru?${urlParams}`).then(r => r.json()), + fetch(`${API_BASE}/api/danh-sach-chuyen-vien?database=${database}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`).then(r => r.json()), + fetch(`${API_BASE}/api/danh-sach-theo-doi?database=${database}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`).then(r => r.json()), + ]); + + let hasSuccess = false; + let errors = []; + + // 1. Ngoại trú + if (resNt.status === 'fulfilled' && resNt.value && resNt.value.success) { + rawNgoaiTruData = resNt.value.data; + renderNgoaiTru(); + hasSuccess = true; + } else { + const err = resNt.status === 'rejected' ? resNt.reason?.message : resNt.value?.message; + errors.push(`Ngoại trú: ${err || 'Lỗi kết nối'}`); + const body = document.getElementById('bodyKhoaKcb'); + if (body) body.innerHTML = ` Không tải được số liệu ngoại trú: ${err || 'Lỗi'}`; + } + + // 2. Nội trú + if (resNoiTru.status === 'fulfilled' && resNoiTru.value && resNoiTru.value.success) { + rawNoiTruData = resNoiTru.value.data; + renderNoiTru(); + hasSuccess = true; + } else { + const err = resNoiTru.status === 'rejected' ? resNoiTru.reason?.message : resNoiTru.value?.message; + errors.push(`Nội trú: ${err || 'Lỗi kết nối'}`); + const body = document.getElementById('bodyNoiTru'); + if (body) body.innerHTML = ` Không tải được số liệu nội trú: ${err || 'Lỗi'}`; + } + + // 3. Bảng 3: Chuyển viện + if (resB3.status === 'fulfilled' && resB3.value && resB3.value.success) { + rawBang3Data = resB3.value.data; + populateKhoaDropdown('filterKhoaCv', rawBang3Data); + renderBang3('ALL'); + hasSuccess = true; + } else { + const err = resB3.status === 'rejected' ? resB3.reason?.message : resB3.value?.message; + errors.push(`Chuyển viện: ${err || 'Lỗi kết nối'}`); + const body = document.getElementById('bodyBang3'); + if (body) body.innerHTML = ` Không tải được danh sách chuyển viện`; + } + + // 4. Bảng 4: Theo dõi + if (resB4.status === 'fulfilled' && resB4.value && resB4.value.success) { + rawBang4Data = resB4.value.data; + populateKhoaDropdown('filterKhoaTd', rawBang4Data); + renderBang4('ALL'); + hasSuccess = true; + } else { + const err = resB4.status === 'rejected' ? resB4.reason?.message : resB4.value?.message; + errors.push(`Theo dõi: ${err || 'Lỗi kết nối'}`); + const body = document.getElementById('bodyBang4'); + if (body) body.innerHTML = ` Không tải được danh sách theo dõi`; + } + + if (hasSuccess) { + const selName = document.getElementById('selectDb').selectedOptions[0]?.textContent.split('(')[0].trim() || database; + showToast(`Đã nạp báo cáo CSDL: ${selName}`); + } + if (errors.length > 0) { + console.warn('Errors loading some report sections:', errors); + showToast(`Cảnh báo: ${errors.join('; ')}`); + } + } catch (err) { + console.error('Fatal load error:', err); + showToast('Lỗi nghiêm trọng khi tải dữ liệu: ' + err.message); + } finally { + setLoading(false); + } +} + +// Populate Khoa Dropdown +function populateKhoaDropdown(selectId, dataList) { + const sel = document.getElementById(selectId); + const currentVal = sel.value; + const depts = Array.from(new Set(dataList.map(item => item.tendv).filter(Boolean))); + sel.innerHTML = ''; + depts.forEach(d => { + sel.innerHTML += ``; + }); + if (depts.includes(currentVal)) { + sel.value = currentVal; + } +} + +// ======================================================== +// RENDER NGOẠI TRÚ +// ======================================================== +function renderNgoaiTru() { + if (!rawNgoaiTruData) return; + const cur = rawNgoaiTruData.current; + const comp = rawNgoaiTruData.compare; + const isCompare = !!comp; + + // KPIs + let totalKham = 0, totalBhyt = 0, totalThuPhi = 0, totalChuyenVien = 0; + cur.khoa.forEach(k => { + totalKham += parseInt(k.tong_kham || 0, 10); + totalBhyt += parseInt(k.bhyt || 0, 10); + totalThuPhi += parseInt(k.thuphi || 0, 10); + totalChuyenVien += parseInt(k.chuyenvien || 0, 10); + }); + + document.getElementById('kpiTongKham').innerText = fmt(totalKham); + document.getElementById('kpiBhyt').innerText = fmt(totalBhyt); + document.getElementById('kpiBhytPct').innerText = totalKham > 0 ? (totalBhyt / totalKham * 100).toFixed(1) + '% tổng lượt' : '0%'; + document.getElementById('kpiThuPhi').innerText = fmt(totalThuPhi); + document.getElementById('kpiThuPhiPct').innerText = totalKham > 0 ? (totalThuPhi / totalKham * 100).toFixed(1) + '% tổng lượt' : '0%'; + document.getElementById('kpiChuyenVien').innerText = fmt(totalChuyenVien); + document.getElementById('kpiTiemNgua').innerText = fmt(cur.tiemngua); + + // BANT + document.getElementById('kpiBantTong').innerText = fmt(cur.bant.tong); + document.getElementById('kpiBantMoi').innerText = fmt(cur.bant.moi); + document.getElementById('kpiBantTk').innerText = fmt(cur.bant.taikham); + + // YDCT + document.getElementById('ydctDienCham').innerText = fmt(cur.ydct.dien_cham); + document.getElementById('ydctVltl').innerText = fmt(cur.ydct.vltl); + document.getElementById('ydctSacThuoc').innerText = fmt(cur.ydct.sac_thuoc); + document.getElementById('ydctCayChi').innerText = fmt(cur.ydct.cay_chi); + + // Table Khoa + const tbody = document.getElementById('bodyKhoaKcb'); + tbody.innerHTML = ''; + + const compMap = {}; + if (comp && comp.khoa) { + comp.khoa.forEach(k => compMap[k.madv] = k); + } + + 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 { + cur.khoa.forEach(row => { + const k1 = parseInt(row.tong_kham || 0, 10); + 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'); + compCols = ` + ${fmt(k2)} + ${diff > 0 ? '+' : ''}${fmt(diff)} (${pct}) + `; + } + + tbody.innerHTML += ` + + ${row.madv} + ${row.tendv} + ${fmt(row.bhyt)} + ${fmt(row.thuphi)} + ${fmt(row.chuyenvien)} + ${fmt(row.tong_kham)} + ${compCols} + + `; + }); + } + + // Table Khoa Footer + const tfoot = document.getElementById('footKhoaKcb'); + let compFoot = ''; + if (isCompare && comp && comp.khoa) { + let compTotalKham = 0; + comp.khoa.forEach(k => compTotalKham += parseInt(k.tong_kham || 0, 10)); + const totalDiff = totalKham - compTotalKham; + const totalPct = compTotalKham > 0 ? ((totalDiff / compTotalKham) * 100).toFixed(1) + '%' : '0%'; + compFoot = ` + ${fmt(compTotalKham)} + ${totalDiff > 0 ? '+' : ''}${fmt(totalDiff)} (${totalPct}) + `; + } + tfoot.innerHTML = ` + + TỔNG CỘNG + ${fmt(totalBhyt)} + ${fmt(totalThuPhi)} + ${fmt(totalChuyenVien)} + ${fmt(totalKham)} + ${compFoot} + + `; + + // CLS Table + const bodyCls = document.getElementById('bodyCls'); + bodyCls.innerHTML = ''; + if (!cur.cls || cur.cls.length === 0) { + bodyCls.innerHTML = 'Không có chỉ định Cận lâm sàng nào trong kỳ'; + } else { + cur.cls.forEach(c => { + const nhomBadge = c.kho === 'XN' + ? 'Xét nghiệm' + : 'CĐHA / TDCN'; + + bodyCls.innerHTML += ` + + ${nhomBadge} + ${c.tenloai} (${c.maloai}) + ${fmt(c.so_bn)} + ${fmt(c.so_luot)} + + `; + }); + } + + // Top 10 Table + const bodyTop10 = document.getElementById('bodyTop10'); + bodyTop10.innerHTML = ''; + if (!cur.top10 || cur.top10.length === 0) { + bodyTop10.innerHTML = 'Không có dữ liệu chẩn đoán'; + } else { + cur.top10.forEach((item, idx) => { + bodyTop10.innerHTML += ` + + ${item.maicd} + ${item.kqcdoan} + ${fmt(item.sl)} + + `; + }); + } + + // Render Outpatient Chart + renderChartNgoaiTru(); +} + +// Render Chart Ngoại Trú (Chart.js) +function renderChartNgoaiTru() { + if (!rawNgoaiTruData) return; + const canvas = document.getElementById('chartNgoaiTru'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const cur = rawNgoaiTruData.current || {}; + const comp = rawNgoaiTruData.compare; + + if (chartNgoaiTruInstance) { + chartNgoaiTruInstance.destroy(); + chartNgoaiTruInstance = null; + } + + const khoaList = Array.isArray(cur.khoa) ? cur.khoa : []; + const clsList = Array.isArray(cur.cls) ? cur.cls : []; + + let labels = []; + let data1 = []; + let data2 = []; + + if (currentChartTypeNgoaiTru === 'khoa') { + labels = khoaList.slice(0, 7).map(k => k.tendv); + data1 = khoaList.slice(0, 7).map(k => parseInt(k.tong_kham || 0, 10)); + + if (comp && Array.isArray(comp.khoa)) { + const compMap = {}; + comp.khoa.forEach(k => compMap[k.madv] = k); + data2 = khoaList.slice(0, 7).map(k => parseInt(compMap[k.madv]?.tong_kham || 0, 10)); + } + } else { + labels = clsList.slice(0, 8).map(c => c.tenloai); + data1 = clsList.slice(0, 8).map(c => parseInt(c.so_luot || 0, 10)); + + if (comp && Array.isArray(comp.cls)) { + const compMap = {}; + comp.cls.forEach(c => compMap[c.maloai] = c); + data2 = clsList.slice(0, 8).map(c => parseInt(compMap[c.maloai]?.so_luot || 0, 10)); + } + } + + const datasets = [ + { + label: comp ? 'Kỳ hiện tại' : 'Lượt thực hiện', + data: data1, + backgroundColor: 'rgba(37, 99, 235, 0.85)', + borderRadius: 4, + } + ]; + + if (comp) { + datasets.push({ + label: 'Kỳ so sánh', + data: data2, + backgroundColor: 'rgba(234, 88, 12, 0.85)', + borderRadius: 4, + }); + } + + chartNgoaiTruInstance = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: datasets, + }, + 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: 30, + minRotation: 0, + }, + }, + }, + }, + }); +} + +// ======================================================== +// RENDER NỘI TRÚ +// ======================================================== +function renderNoiTru() { + if (!rawNoiTruData) return; + const cur = rawNoiTruData.current || {}; + const comp = rawNoiTruData.compare; + + let totalCu = 0, totalMoi = 0, totalOn = 0, totalNang = 0, totalTron = 0, totalCv = 0, totalTv = 0, totalHienCon = 0, totalTd = 0, totalCs1 = 0; + + const tbody = document.getElementById('bodyNoiTru'); + tbody.innerHTML = ''; + + const khoaList = Array.isArray(cur.khoa) ? cur.khoa : []; + + if (khoaList.length === 0) { + tbody.innerHTML = 'Không có dữ liệu nội trú trong khoảng thời gian này'; + } else { + khoaList.forEach(row => { + const cu = parseInt(row.benh_cu || 0, 10); + const moi = parseInt(row.benh_moi || 0, 10); + const on = parseInt(row.on_xin_ve || 0, 10); + const nang = parseInt(row.nang_xin_ve || 0, 10); + const tron = parseInt(row.tron_vien || 0, 10); + const cv = parseInt(row.chuyen_vien || 0, 10); + const tv = parseInt(row.tu_vong || 0, 10); + const hiencon = parseInt(row.benh_hien_con || 0, 10); + const td = parseInt(row.benh_theo_doi || 0, 10); + const cs1 = parseInt(row.chamsoc_cap1 || 0, 10); + + totalCu += cu; totalMoi += moi; totalOn += on; totalNang += nang; + totalTron += tron; totalCv += cv; totalTv += tv; + totalHienCon += hiencon; totalTd += td; totalCs1 += cs1; + + tbody.innerHTML += ` + + ${row.madv} + ${row.tendv} + ${fmt(cu)} + ${fmt(moi)} + ${fmt(on)} + ${fmt(nang)} + ${fmt(tron)} + ${fmt(cv)} + ${fmt(tv)} + ${fmt(hiencon)} + ${fmt(td)} + ${fmt(cs1)} + + `; + }); + } + + // KPI Nội Trú + const totalXuatVien = totalOn + totalNang + totalTron + totalCv + totalTv; + document.getElementById('kpiNtBenhCu').innerText = fmt(totalCu); + document.getElementById('kpiNtBenhMoi').innerText = fmt(totalMoi); + document.getElementById('kpiNtXuatVien').innerText = fmt(totalXuatVien); + document.getElementById('kpiNtOnPct').innerText = `Ổn về: ${fmt(totalOn)} ca`; + document.getElementById('kpiNtHienCon').innerText = fmt(totalHienCon); + document.getElementById('kpiNtTheoDoi').innerText = fmt(totalTd); + document.getElementById('kpiNtCsCap1').innerText = fmt(totalCs1); + + // Footer NoiTru Table + document.getElementById('footNoiTru').innerHTML = ` + + TỔNG CỘNG + ${fmt(totalCu)} + ${fmt(totalMoi)} + ${fmt(totalOn)} + ${fmt(totalNang)} + ${fmt(totalTron)} + ${fmt(totalCv)} + ${fmt(totalTv)} + ${fmt(totalHienCon)} + ${fmt(totalTd)} + ${fmt(totalCs1)} + + `; + + // Render Inpatient Chart + renderChartNoiTru(); +} + +// Render Chart Nội Trú +function renderChartNoiTru() { + if (!rawNoiTruData) return; + const canvas = document.getElementById('chartNoiTru'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const cur = rawNoiTruData.current || {}; + const comp = rawNoiTruData.compare; + + if (chartNoiTruInstance) { + chartNoiTruInstance.destroy(); + chartNoiTruInstance = null; + } + + const khoaList = Array.isArray(cur.khoa) ? cur.khoa : []; + const topKhoa = khoaList.slice(0, 8); + const labels = topKhoa.map(k => k.tendv); + const dataMoi = topKhoa.map(k => parseInt(k.benh_moi || 0, 10)); + const dataRaVien = 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)); + + const datasets = [ + { + label: 'Bệnh mới vào viện', + data: dataMoi, + backgroundColor: 'rgba(37, 99, 235, 0.85)', + borderRadius: 4, + }, + { + label: 'Tổng xuất viện', + data: dataRaVien, + backgroundColor: 'rgba(16, 185, 129, 0.85)', + borderRadius: 4, + }, + { + label: 'Hiện còn đang điều trị', + data: dataHienCon, + backgroundColor: 'rgba(99, 102, 241, 0.85)', + borderRadius: 4, + } + ]; + + if (comp && Array.isArray(comp.khoa)) { + const compMap = {}; + comp.khoa.forEach(k => compMap[k.madv] = k); + const dataMoiComp = topKhoa.map(k => parseInt(compMap[k.madv]?.benh_moi || 0, 10)); + datasets.push({ + label: 'Bệnh mới (Kỳ so sánh)', + data: dataMoiComp, + backgroundColor: 'rgba(234, 88, 12, 0.85)', + borderRadius: 4, + }); + } + + chartNoiTruInstance = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: datasets, + }, + 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 } }, + }, + }, + }); +} + +// ======================================================== +// RENDER BẢNG 3: BỆNH NHÂN CHUYỂN VIỆN +// ======================================================== +function renderBang3(filterDept) { + const tbody = document.getElementById('bodyBang3'); + tbody.innerHTML = ''; + + const filtered = filterDept === 'ALL' + ? rawBang3Data + : rawBang3Data.filter(item => item.tendv === filterDept); + + document.getElementById('countBang3').innerText = `${filtered.length} ca`; + + if (filtered.length === 0) { + tbody.innerHTML = `Không có bệnh nhân chuyển viện trong thời gian này`; + return; + } + + filtered.forEach((row, index) => { + tbody.innerHTML += ` + + ${index + 1} + +
${row.mabn}
+
${row.maba}
+ + ${row.hoten} + ${row.diachi} + +
${row.ngayvv}
+
${row.ngayrv}
+ + ${row.tendv} + +
${row.chandoan_chinh}
+ ${row.chandoan_phu ? `
${row.chandoan_phu}
` : ''} + + ${row.ketqua_cls} + + + + + `; + }); + + attachNoteHandlers(); +} + +// ======================================================== +// RENDER BẢNG 4: BỆNH NHÂN THEO DÕI & CS CẤP I +// ======================================================== +function renderBang4(filterDept) { + const tbody = document.getElementById('bodyBang4'); + tbody.innerHTML = ''; + + const filtered = filterDept === 'ALL' + ? rawBang4Data + : rawBang4Data.filter(item => item.tendv === filterDept); + + document.getElementById('countBang4').innerText = `${filtered.length} ca`; + + if (filtered.length === 0) { + tbody.innerHTML = `Không có bệnh nhân cần theo dõi hoặc CS Cấp I`; + return; + } + + filtered.forEach((row, index) => { + tbody.innerHTML += ` + + ${index + 1} + +
${row.mabn}
+
${row.maba}
+ + ${row.hoten} + ${row.diachi} + ${row.ngayvv} + +
${row.tendv}
+ + ${row.chamsoc} + + + +
${row.chandoan_chinh}
+ ${row.chandoan_phu ? `
${row.chandoan_phu}
` : ''} + + ${row.ketqua_cls} + + + + + `; + }); + + attachNoteHandlers(); +} + +// INLINE NOTE AUTO-SAVE HANDLER +function attachNoteHandlers() { + document.querySelectorAll('.table-cell-edit').forEach(el => { + el.removeEventListener('change', handleNoteChange); + el.addEventListener('change', handleNoteChange); + }); +} + +async function handleNoteChange(e) { + const el = e.target; + const maba = el.getAttribute('data-maba'); + const mabn = el.getAttribute('data-mabn'); + const loai = el.getAttribute('data-loai'); + const ghichu = el.value.trim(); + const database = document.getElementById('selectDb').value; + + el.classList.add('saving'); + try { + const res = await fetch(`${API_BASE}/api/cap-nhat-ghi-chu`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ maba, mabn, loai, ghichu, database }), + }); + const result = await res.json(); + if (result.success) { + el.classList.remove('saving'); + el.classList.add('saved'); + showToast(`Đã lưu ghi chú BN ${mabn}`); + setTimeout(() => el.classList.remove('saved'), 2000); + } else { + throw new Error(result.message); + } + } catch (err) { + el.classList.remove('saving'); + alert('Lỗi lưu ghi chú: ' + err.message); + } +} + +// Export Excel Helper +function exportExcel() { + const activeTab = document.querySelector('.tab-btn.active').getAttribute('data-tab'); + const table = activeTab === 'tab-ngoaitru' + ? document.getElementById('tableKhoaKcb') + : document.getElementById('tableNoiTru'); + + let html = table.outerHTML; + let blob = new Blob(['\ufeff' + html], { type: 'application/vnd.ms-excel' }); + let url = URL.createObjectURL(blob); + let a = document.createElement('a'); + a.href = url; + a.download = `BaoCaoGiaoBan_${activeTab}_${Date.now()}.xls`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + showToast('Đã xuất tập tin Excel thành công!'); +} + +// ======================================================== +// CHỨC NĂNG QUẢN LÝ ISSUE, BÁO LỖI & LƯU FILE TỔNG HỢP +// ======================================================== + +function initIssueFeatures() { + // Modal Open / Close + const btnOpen1 = document.getElementById('btnOpenIssueModal'); + const btnOpen2 = document.getElementById('btnOpenIssueModal2'); + const btnClose = document.getElementById('btnCloseIssueModal'); + const btnCancel = document.getElementById('btnCancelIssue'); + + if (btnOpen1) btnOpen1.addEventListener('click', openCreateIssueModal); + if (btnOpen2) btnOpen2.addEventListener('click', openCreateIssueModal); + if (btnClose) btnClose.addEventListener('click', closeCreateIssueModal); + if (btnCancel) btnCancel.addEventListener('click', closeCreateIssueModal); + + // Form Submit + const formCreate = document.getElementById('formCreateIssue'); + if (formCreate) formCreate.addEventListener('submit', handleCreateIssue); + + // Update Modal + const btnCloseUp = document.getElementById('btnCloseUpdateModal'); + const btnCancelUp = document.getElementById('btnCancelUpdate'); + if (btnCloseUp) btnCloseUp.addEventListener('click', closeUpdateModal); + if (btnCancelUp) btnCancelUp.addEventListener('click', closeUpdateModal); + + const formUpdate = document.getElementById('formUpdateIssue'); + if (formUpdate) formUpdate.addEventListener('submit', handleUpdateIssue); + + // Lightbox + const btnCloseLb = document.getElementById('btnCloseLightbox'); + const modalLb = document.getElementById('modalImagePreview'); + if (btnCloseLb) btnCloseLb.addEventListener('click', closeLightbox); + if (modalLb) { + modalLb.addEventListener('click', (e) => { + if (e.target === modalLb) closeLightbox(); + }); + } + + // File Upload & Paste Zone + const pasteZone = document.getElementById('issuePasteZone'); + const fileInput = document.getElementById('issueFileInput'); + const btnRemove = document.getElementById('btnRemoveFile'); + + if (pasteZone) { + pasteZone.addEventListener('click', (e) => { + if (e.target.closest('#btnRemoveFile')) return; + fileInput.click(); + }); + } + + if (fileInput) { + fileInput.addEventListener('change', () => { + if (fileInput.files && fileInput.files[0]) { + setIssueFile(fileInput.files[0]); + } + }); + } + + if (btnRemove) { + btnRemove.addEventListener('click', (e) => { + e.stopPropagation(); + clearIssueFile(); + }); + } + + // Clipboard Paste (Ctrl+V) anywhere inside the modal or page + document.addEventListener('paste', (e) => { + const modal = document.getElementById('modalIssue'); + if (!modal || modal.classList.contains('hidden')) return; + + const items = e.clipboardData?.items; + if (!items) return; + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf('image') !== -1) { + const blob = items[i].getAsFile(); + if (blob) { + const ext = blob.type.split('/')[1] || 'png'; + const file = new File([blob], `screenshot_${Date.now()}.${ext}`, { type: blob.type }); + setIssueFile(file); + showToast('Đã dán ảnh chụp màn hình từ Clipboard!'); + break; + } + } + } + }); + + // Filters + const fStatus = document.getElementById('filterIssueStatus'); + const fCat = document.getElementById('filterIssueCategory'); + const fKeyword = document.getElementById('filterIssueKeyword'); + + if (fStatus) fStatus.addEventListener('change', renderIssuesTable); + if (fCat) fCat.addEventListener('change', renderIssuesTable); + if (fKeyword) fKeyword.addEventListener('input', renderIssuesTable); + + // Export Excel + const btnExportIssues = document.getElementById('btnExportIssuesExcel'); + if (btnExportIssues) btnExportIssues.addEventListener('click', exportIssuesExcel); +} + +function setIssueFile(file) { + currentIssueFile = file; + const prompt = document.getElementById('pasteZonePrompt'); + const preview = document.getElementById('issuePreviewArea'); + const nameEl = document.getElementById('issueFileName'); + const sizeEl = document.getElementById('issueFileSize'); + const imgEl = document.getElementById('issueImgPreview'); + const fileIcon = document.getElementById('issueFileIcon'); + + prompt.classList.add('hidden'); + preview.classList.remove('hidden'); + + nameEl.innerText = file.name; + sizeEl.innerText = `${(file.size / 1024).toFixed(1)} KB`; + + if (file.type.startsWith('image/')) { + imgEl.classList.remove('hidden'); + fileIcon.classList.add('hidden'); + const reader = new FileReader(); + reader.onload = (e) => { + imgEl.src = e.target.result; + }; + reader.readAsDataURL(file); + } else { + imgEl.classList.add('hidden'); + fileIcon.classList.remove('hidden'); + } +} + +function clearIssueFile() { + currentIssueFile = null; + const fileInput = document.getElementById('issueFileInput'); + if (fileInput) fileInput.value = ''; + + const prompt = document.getElementById('pasteZonePrompt'); + const preview = document.getElementById('issuePreviewArea'); + prompt.classList.remove('hidden'); + preview.classList.add('hidden'); +} + +function openCreateIssueModal() { + const selectDb = document.getElementById('selectDb'); + const dbName = selectDb.selectedOptions[0]?.textContent || selectDb.value; + const dateFrom = document.getElementById('dateFrom').value; + const dateTo = document.getElementById('dateTo').value; + + document.getElementById('issueDatabase').value = `${dbName} (${selectDb.value})`; + document.getElementById('issuePeriod').value = `Từ ${dateFrom} đến ${dateTo}`; + + document.getElementById('issueTitle').value = ''; + document.getElementById('issueDescription').value = ''; + clearIssueFile(); + + const modal = document.getElementById('modalIssue'); + modal.classList.remove('hidden'); +} + +function closeCreateIssueModal() { + const modal = document.getElementById('modalIssue'); + modal.classList.add('hidden'); +} + +async function handleCreateIssue(e) { + e.preventDefault(); + const btnSubmit = document.getElementById('btnSubmitIssue'); + btnSubmit.disabled = true; + btnSubmit.innerHTML = ' Đang lưu...'; + + try { + const selectDb = document.getElementById('selectDb'); + const dbValue = selectDb.value; + const dbName = selectDb.selectedOptions[0]?.textContent || dbValue; + + const formData = new FormData(); + formData.append('title', document.getElementById('issueTitle').value); + formData.append('category', document.getElementById('issueCategory').value); + formData.append('module', document.getElementById('issueModule').value); + formData.append('priority', document.getElementById('issuePriority').value); + formData.append('database', dbValue); + formData.append('databaseName', dbName); + formData.append('period', document.getElementById('issuePeriod').value); + formData.append('reporter', document.getElementById('issueReporter').value); + formData.append('department', document.getElementById('issueDepartment').value); + formData.append('description', document.getElementById('issueDescription').value); + + if (currentIssueFile) { + formData.append('attachment', currentIssueFile); + } + + const res = await fetch(`${API_BASE}/api/issues`, { + method: 'POST', + body: formData, + }); + const result = await res.json(); + if (result.success) { + showToast('Đã lưu báo lỗi & tệp thành công!'); + closeCreateIssueModal(); + await loadIssues(true); + } else { + throw new Error(result.message); + } + } catch (err) { + alert('Lỗi gửi báo lỗi: ' + err.message); + } finally { + btnSubmit.disabled = false; + btnSubmit.innerHTML = ' Gửi Báo Lỗi & Lưu File'; + } +} + +async function loadIssues(shouldShowToast = false) { + try { + const res = await fetch(`${API_BASE}/api/issues`); + const result = await res.json(); + if (result.success && Array.isArray(result.data)) { + rawIssuesData = result.data; + updateIssueKpis(); + renderIssuesTable(); + if (shouldShowToast) { + showToast(`Đã nạp ${rawIssuesData.length} issue`); + } + } + } catch (err) { + console.error('Error loading issues:', err); + } +} + +function updateIssueKpis() { + const total = rawIssuesData.length; + const pending = rawIssuesData.filter(i => i.status === 'CHO_XU_LY').length; + const processing = rawIssuesData.filter(i => i.status === 'DANG_XU_LY').length; + const resolved = rawIssuesData.filter(i => i.status === 'DA_XU_LY' || i.status === 'DONG').length; + + const totalEl = document.getElementById('issueKpiTotal'); + const pendingEl = document.getElementById('issueKpiPending'); + const procEl = document.getElementById('issueKpiProcessing'); + const resEl = document.getElementById('issueKpiResolved'); + + if (totalEl) totalEl.innerText = total; + if (pendingEl) pendingEl.innerText = pending; + if (procEl) procEl.innerText = processing; + if (resEl) resEl.innerText = resolved; + + const badge = document.getElementById('badgeIssueCount'); + if (badge) { + badge.innerText = pending; + badge.classList.toggle('bg-rose-100', pending > 0); + badge.classList.toggle('text-rose-700', pending > 0); + badge.classList.toggle('bg-slate-100', pending === 0); + badge.classList.toggle('text-slate-500', pending === 0); + } +} + +function renderIssuesTable() { + const tbody = document.getElementById('bodyIssuesList'); + if (!tbody) return; + + const fStatus = document.getElementById('filterIssueStatus')?.value || 'ALL'; + const fCat = document.getElementById('filterIssueCategory')?.value || 'ALL'; + const keyword = (document.getElementById('filterIssueKeyword')?.value || '').toLowerCase().trim(); + + const filtered = rawIssuesData.filter(item => { + if (fStatus !== 'ALL' && item.status !== fStatus) return false; + if (fCat !== 'ALL' && item.category !== fCat) return false; + if (keyword) { + const matchId = (item.id || '').toLowerCase().includes(keyword); + const matchTitle = (item.title || '').toLowerCase().includes(keyword); + const matchDesc = (item.description || '').toLowerCase().includes(keyword); + const matchReporter = (item.reporter || '').toLowerCase().includes(keyword); + const matchDept = (item.department || '').toLowerCase().includes(keyword); + if (!matchId && !matchTitle && !matchDesc && !matchReporter && !matchDept) return false; + } + return true; + }); + + if (filtered.length === 0) { + tbody.innerHTML = 'Không có issue nào phù hợp với bộ lọc.'; + return; + } + + tbody.innerHTML = ''; + filtered.forEach(row => { + // Badges + const catBadge = getCategoryBadge(row.category); + const priBadge = getPriorityBadge(row.priority); + const statusBadge = getStatusBadge(row.status); + const createdStr = formatDateTime(row.createdAt); + + // Attachment + let attachHtml = '-'; + if (row.attachment) { + const attachUrl = `${API_BASE}${row.attachment.url}`; + if (row.attachment.mimetype && row.attachment.mimetype.startsWith('image/')) { + attachHtml = ` + + `; + } else { + attachHtml = ` + + Tải file + + `; + } + } + + // Handler notes + let notesHtml = ''; + if (row.handlerNotes) { + notesHtml = ` +
+ Xử lý (${escapeHtml(row.resolvedBy || 'IT')}): ${escapeHtml(row.handlerNotes)} +
+ `; + } + + tbody.innerHTML += ` + + +
${row.id}
+
${createdStr}
+ + +
${escapeHtml(row.title)}
+
${escapeHtml(row.description)}
+ ${notesHtml} + + +
+
${catBadge}
+
${getModuleLabel(row.module)}
+
${priBadge}
+
+ + +
+ ${escapeHtml(row.databaseName || row.database)} +
+
${escapeHtml(row.period || '-')}
+ + +
${escapeHtml(row.reporter || 'Cán bộ')}
+
${escapeHtml(row.department || '-')}
+ + + ${attachHtml} + + + ${statusBadge} + + +
+ + +
+ + + `; + }); +} + +function getCategoryBadge(cat) { + switch (cat) { + case 'SAI_LECH_SO_LIEU': + return 'Sai lệch số liệu'; + case 'LOI_GIAO_DIEN': + return 'Lỗi giao diện'; + case 'LOI_KET_NOI': + return 'Lỗi kết nối'; + case 'DE_XUAT_CAI_TIEN': + return 'Đề xuất cải tiến'; + case 'YEU_CAU_MOI': + return 'Yêu cầu mới'; + default: + return 'Khác'; + } +} + +function getPriorityBadge(pri) { + switch (pri) { + case 'KHAN_CAP': + return 'Khẩn cấp'; + case 'CAO': + return 'Cao'; + case 'THAP': + return 'Thấp'; + default: + return 'Bình thường'; + } +} + +function getStatusBadge(st) { + switch (st) { + case 'CHO_XU_LY': + return ' Chờ xử lý'; + case 'DANG_XU_LY': + return ' Đang xử lý'; + case 'DA_XU_LY': + return ' Đã xử lý'; + case 'DONG': + return ' Đã đóng'; + default: + return st; + } +} + +function getModuleLabel(mod) { + switch (mod) { + case 'NGOAI_TRU': return '1. Ngoại trú'; + case 'NOI_TRU': return '2. Nội trú'; + case 'CHUYEN_VIEN': return 'Bảng 3. Chuyển viện'; + case 'THEO_DOI': return 'Bảng 4. BN Theo dõi'; + default: return 'Toàn hệ thống'; + } +} + +function formatDateTime(iso) { + if (!iso) return '-'; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + const day = String(d.getDate()).padStart(2, '0'); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const year = d.getFullYear(); + const hours = String(d.getHours()).padStart(2, '0'); + const minutes = String(d.getMinutes()).padStart(2, '0'); + return `${day}/${month}/${year} ${hours}:${minutes}`; +} + +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function openUpdateModal(issueId) { + const item = rawIssuesData.find(i => i.id === issueId); + if (!item) return; + + editingIssueId = issueId; + document.getElementById('updateModalIssueId').innerText = issueId; + document.getElementById('updateStatus').value = item.status || 'CHO_XU_LY'; + document.getElementById('updateResolvedBy').value = item.resolvedBy || ''; + document.getElementById('updateHandlerNotes').value = item.handlerNotes || ''; + + const modal = document.getElementById('modalUpdateIssue'); + modal.classList.remove('hidden'); +} + +function closeUpdateModal() { + const modal = document.getElementById('modalUpdateIssue'); + modal.classList.add('hidden'); + editingIssueId = null; +} + +async function handleUpdateIssue(e) { + e.preventDefault(); + if (!editingIssueId) return; + + const btn = document.getElementById('btnSaveUpdateIssue'); + btn.disabled = true; + btn.innerHTML = ' Đang lưu...'; + + try { + const status = document.getElementById('updateStatus').value; + const resolvedBy = document.getElementById('updateResolvedBy').value.trim(); + const handlerNotes = document.getElementById('updateHandlerNotes').value.trim(); + + const res = await fetch(`${API_BASE}/api/issues/${editingIssueId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status, resolvedBy, handlerNotes }), + }); + + const result = await res.json(); + if (result.success) { + showToast(`Đã cập nhật issue ${editingIssueId}`); + closeUpdateModal(); + await loadIssues(false); + } else { + throw new Error(result.message); + } + } catch (err) { + alert('Lỗi cập nhật issue: ' + err.message); + } finally { + btn.disabled = false; + btn.innerHTML = ' Lưu Cập Nhật'; + } +} + +async function handleDeleteIssue(issueId) { + if (!confirm(`Bạn có chắc muốn xóa Issue [${issueId}] này không?`)) return; + + try { + const res = await fetch(`${API_BASE}/api/issues/${issueId}`, { + method: 'DELETE', + }); + const result = await res.json(); + if (result.success) { + showToast(`Đã xóa issue ${issueId}`); + await loadIssues(false); + } else { + throw new Error(result.message); + } + } catch (err) { + alert('Lỗi xóa issue: ' + err.message); + } +} + +function openImageLightbox(url, caption) { + const modal = document.getElementById('modalImagePreview'); + const img = document.getElementById('lightboxImg'); + const cap = document.getElementById('lightboxCaption'); + img.src = url; + cap.innerText = caption || 'Hình ảnh sự cố'; + modal.classList.remove('hidden'); +} + +function closeLightbox() { + const modal = document.getElementById('modalImagePreview'); + modal.classList.add('hidden'); +} + +function exportIssuesExcel() { + if (rawIssuesData.length === 0) { + alert('Chưa có issue nào để xuất file!'); + return; + } + + let tableHtml = ` + + + + + + + + + + + + + + + + + + + + + + + `; + + rawIssuesData.forEach(row => { + tableHtml += ` + + + + + + + + + + + + + + + + + + + `; + }); + + tableHtml += '
Mã IssueNgày Báo CáoTiêu ĐềPhân LoạiPhân HệMức ĐộCSDLKỳ Thời GianNgười BáoKhoa / PhòngNội Dung Chi TiếtTrạng TháiKỹ Thuật Xử LýGhi Chú Giải PhápNgày Giải QuyếtTệp Đính Kèm
${escapeHtml(row.id)}${formatDateTime(row.createdAt)}${escapeHtml(row.title)}${escapeHtml(row.category)}${escapeHtml(row.module)}${escapeHtml(row.priority)}${escapeHtml(row.databaseName || row.database)}${escapeHtml(row.period)}${escapeHtml(row.reporter)}${escapeHtml(row.department)}${escapeHtml(row.description)}${escapeHtml(row.status)}${escapeHtml(row.resolvedBy || '')}${escapeHtml(row.handlerNotes || '')}${formatDateTime(row.resolvedAt)}${row.attachment ? escapeHtml(row.attachment.originalname) : ''}
'; + + const blob = new Blob(['\ufeff' + tableHtml], { type: 'application/vnd.ms-excel;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `DanhSach_Issues_GiaoBan_${Date.now()}.xls`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + showToast('Đã xuất file Excel tổng hợp issue thành công!'); +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9ab316461f79540beef7340d76e0f8f4fb6835df GIT binary patch literal 30568 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 Z(EkSu{dVLH8;#*(QI>-r|Nl$1|34$vK2!hz literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a423657 --- /dev/null +++ b/public/index.html @@ -0,0 +1,899 @@ + + + + + + DH-HIS | Quản Lý Giao Ban Hằng Ngày + + + + + + + + + + + + +
+
+
+
+ +
+
+

+ Phần Mềm Quản Lý Giao Ban Bệnh Viện + DH-HIS Web + + 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ỳ

+
+
+ + +
+ + +
+
+
+ + +
+
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + + + +
+
+ + + +
+
+ + +
+
+ + + +
+
+ + +
+ + + + +
+ + +
+
+
+ Tổng Khám + +
+
0
+
Lượt khám hoàn tất
+
+ +
+
+ Khám BHYT + +
+
0
+
0% tổng lượt
+
+ +
+
+ Khám Thu Phí + +
+
0
+
0% tổng lượt
+
+ +
+
+ Chuyển Viện + +
+
0
+
Tuyến trên & ngang
+
+ +
+
+ Tiêm Ngừa + +
+
0
+
Lượt tiêm chủng
+
+ +
+
+ Bệnh Án Ngoại Trú + +
+
0
+
+ Mới: 0 + Tái khám: 0 +
+
+
+ + +
+ + +
+
+

+ + 1.1. Số Liệu Khám Chữa Bệnh Theo Các Khoa +

+ Khám bệnh, Nội, Ngoại, CSSKSS, Nhi, YDCT +
+
+ + + + + + + + + + + + + + + + + + + +
MãKhoa Khám BệnhBHYTThu PhíChuyển ViệnTổng Khám
Đang tải dữ liệu...
+
+
+ + +
+ +
+
+

+ + 1.2. Chuyên Khoa Y Dược Cổ Truyền +

+ YDCT +
+
+
+ + Điện châm + + 0 +
+
+ + Vật lý trị liệu (VLTL) + + 0 +
+
+ + Sắc thuốc thang + + 0 +
+
+ + Cấy chỉ (Chôn chỉ) + + 0 +
+
+
+ + +
+
+

+ + Top 10 Chẩn Đoán Ngoại Trú +

+
+
+ + + + +
Đang tải dữ liệu...
+
+
+ +
+
+ + +
+ + +
+
+

+ + 1.3. Tổng Hợp Số Liệu Cận Lâm Sàng +

+ Xét nghiệm & CĐHA +
+
+ + + + + + + + + + + + +
Phân nhómLoại Kỹ ThuậtSố Bệnh NhânSố Lượt Thực Hiện
Đang tải dữ liệu...
+
+
+ + +
+
+

+ + Biểu Đồ Cột So Sánh Số Liệu +

+
+ + +
+
+
+ +
+
+ +
+ +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + Thông báo +
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..496fd59 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,54 @@ +/* Custom styles for DH-HIS Daily Briefing Web */ +@layer base { + body { + background-color: #f8fafc; + } +} + +.table-cell-edit { + background-color: #fffbeb; + transition: all 0.2s; + outline: none; + border-radius: 4px; + padding: 4px 6px; + width: 100%; + border: 1px dashed #f59e0b; +} + +.table-cell-edit:focus { + background-color: #ffffff; + border: 1px solid #2563eb; + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); +} + +.table-cell-edit.saving { + background-color: #fef3c7; + color: #92400e; +} + +.table-cell-edit.saved { + background-color: #ecfdf5; + border-color: #10b981; +} + +/* Print styling */ +@media print { + header, section, .tab-btn, .chart-type-btn, #btnFilter, #btnPrint, #btnExport, #toast { + display: none !important; + } + body { + background: white !important; + font-size: 10pt; + } + .tab-content { + display: block !important; + } + table { + border: 1px solid #ccc !important; + page-break-inside: auto; + } + tr { + page-break-inside: avoid; + page-break-after: auto; + } +} diff --git a/restart.sh b/restart.sh new file mode 100755 index 0000000..ed6351b --- /dev/null +++ b/restart.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Script khởi động lại dịch vụ web-giao-ban +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +"$DIR/stop.sh" +sleep 1 +"$DIR/start.sh" diff --git a/server.js b/server.js new file mode 100644 index 0000000..c621447 --- /dev/null +++ b/server.js @@ -0,0 +1,533 @@ +const express = require('express'); +const cors = require('cors'); +const path = require('path'); +const multer = require('multer'); +const db = require('./db'); +const issueService = require('./issueService'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(cors()); +app.use(express.json()); + +const router = express.Router(); +router.use(express.static(path.join(__dirname, 'public'))); +router.use('/uploads', express.static(path.join(__dirname, 'uploads'))); + +// Multer storage for issue attachments +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + issueService.ensureDirs(); + cb(null, issueService.UPLOADS_DIR); + }, + filename: function (req, file, cb) { + const ext = path.extname(file.originalname); + const safeBase = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9_-]/g, '_'); + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e6); + cb(null, `${safeBase}_${uniqueSuffix}${ext}`); + } +}); + +const upload = multer({ + storage: storage, + limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max +}); + +// Ensure giaoban_ghichu table exists +async function ensureTable(dbName) { + try { + await db.query(` + CREATE TABLE IF NOT EXISTS current.giaoban_ghichu ( + id SERIAL PRIMARY KEY, + maba VARCHAR(50) NOT NULL, + mabn VARCHAR(20) NOT NULL, + loai_danhsach VARCHAR(20) NOT NULL, + ghichu TEXT, + ngay_capnhat TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unq_giaoban_item_${dbName ? dbName.replace(/[^a-zA-Z0-9]/g, '_') : 'default'} UNIQUE(maba, loai_danhsach) + ); + `, [], dbName); + } catch (err) { + // If permission or table exists, ignore + } +} + +// 1. Danh sách database +router.get('/api/databases', async (req, res) => { + try { + const list = [ + { id: 'an_omon_full_04052026', name: 'BVĐK Ô Môn - Cần Thơ (33 GB - Đầy đủ khoa phòng chuẩn)' }, + { id: 'tuan_dbgovap_full', name: 'BVĐK Gò Vấp - TP.HCM (65 GB - Dữ liệu lớn)' }, + { id: 'binh_bvq12_062026052026042026032026022026', name: 'BV Quận 12 - TP.HCM (21 GB)' }, + { id: 'bvq12_697', name: 'BV Quận 12 - Bản 697 (17 GB)' }, + { id: 'an_dalieu_full_05082026', name: 'BV Da Liễu (31 GB)' }, + ]; + res.json({ success: true, data: list }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}); + +// Helper function to build namkt and thangkt partition filter +function getThangNamFilter(fromDateStr, toDateStr, prefix = '') { + try { + const f = new Date(fromDateStr); + const t = new Date(toDateStr); + if (isNaN(f.getTime()) || isNaN(t.getTime())) return ''; + + const thangs = new Set(); + const nams = new Set(); + let curr = new Date(f.getFullYear(), f.getMonth(), 1); + const end = new Date(t.getFullYear(), t.getMonth(), 1); + let count = 0; + while (curr <= end && count < 24) { + thangs.add(String(curr.getMonth() + 1).padStart(2, '0')); + nams.add(String(curr.getFullYear())); + curr.setMonth(curr.getMonth() + 1); + count++; + } + const p = prefix ? `${prefix}.` : ''; + const thangsArr = Array.from(thangs).map(m => `'${m}'`).join(','); + const namsArr = Array.from(nams).map(y => `'${y}'`).join(','); + return ` AND ${p}namkt IN (${namsArr}) AND ${p}thangkt IN (${thangsArr}) `; + } catch (e) { + return ''; + } +} + +// Helper for Outpatient query +async function getOutpatientData(database, fromDate, toDate) { + const params = [fromDate, toDate]; + const fnKb = getThangNamFilter(fromDate, toDate, 'kb'); + const fnLs = getThangNamFilter(fromDate, toDate, 'ls'); + const fnNone = getThangNamFilter(fromDate, toDate, ''); + + // A. Thống kê theo Khoa khám bệnh + const sqlKhoa = ` + SELECT + COALESCE(dv.madv, 'KHAC') AS madv, + COALESCE(dv.tendv, 'Khoa khác') AS tendv, + COUNT(kb.makb) AS tong_kham, + SUM(CASE WHEN dt.bhyt IN (1, 2) THEN 1 ELSE 0 END) AS bhyt, + SUM(CASE WHEN dt.bhyt IN (0, 4) THEN 1 ELSE 0 END) AS thuphi, + SUM(CASE WHEN kb.dakham = 6 THEN 1 ELSE 0 END) AS chuyenvien + FROM current.khambenh kb + LEFT JOIN current.psdangky dk ON kb.mabn = dk.mabn AND kb.makb = dk.makb AND COALESCE(dk.xoa, 0) = 0 + LEFT JOIN current.dmdoituong dt ON dk.madt = dt.madt + LEFT JOIN current.dmdonvi dv ON kb.madv = dv.madv + WHERE kb.ngaykcb BETWEEN $1 AND $2 ${fnKb} + AND COALESCE(kb.xoa, 0) = 0 + AND COALESCE(kb.dakham, 0) != 0 + GROUP BY dv.madv, dv.tendv + ORDER BY tong_kham DESC; + `; + + // B. Bệnh án ngoại trú (Mới vs Tái khám) + const sqlBANT = ` + SELECT + SUM(CASE WHEN COALESCE(dk.taikham, 0) = 0 THEN 1 ELSE 0 END) AS moi, + SUM(CASE WHEN COALESCE(dk.taikham, 0) = 1 THEN 1 ELSE 0 END) AS taikham, + COUNT(*) AS tong + FROM current.bnnoitru nt + JOIN current.psdangky dk ON nt.mabn = dk.mabn AND nt.makb = dk.makb AND COALESCE(dk.xoa, 0) = 0 + WHERE nt.namvien = 0 AND COALESCE(nt.xoa, 0) = 0 + AND nt.ngayvv BETWEEN $1 AND $2; + `; + + // C. Chuyên khoa YDCT + const sqlYDCT = ` + SELECT + SUM(CASE WHEN cls.tencls ILIKE '%Điện châm%' THEN ls.soluong ELSE 0 END) AS dien_cham, + SUM(CASE WHEN cls.tencls ILIKE '%Vật lý trị liệu%' OR cls.tencls ILIKE '%VLTL%' OR cls.macls IN ('DI131','DI135') THEN ls.soluong ELSE 0 END) AS vltl, + SUM(CASE WHEN cls.tencls ILIKE '%Sắc thuốc%' THEN ls.soluong ELSE 0 END) AS sac_thuoc, + SUM(CASE WHEN cls.tencls ILIKE '%Cấy chỉ%' OR cls.tencls ILIKE '%Chôn chỉ%' THEN ls.soluong ELSE 0 END) AS cay_chi + FROM current.chidinhcls ls + JOIN current.dmcls cls ON ls.macls = cls.macls + WHERE ls.ngaykcb BETWEEN $1 AND $2 ${fnLs} + AND COALESCE(ls.xoa, 0) = 0; + `; + + // D. Cận lâm sàng + const sqlCLS = ` + SELECT + cls.kho, + cls.maloai, + COALESCE(loaicls.tenloai, cls.maloai) AS tenloai, + COUNT(DISTINCT ls.makb) AS so_bn, + COUNT(*) AS so_luot + FROM current.chidinhcls ls + JOIN current.dmcls cls ON ls.macls = cls.macls + LEFT JOIN current.dmloaicls loaicls ON cls.maloai = loaicls.maloai + WHERE ls.ngaykcb BETWEEN $1 AND $2 ${fnLs} + AND COALESCE(ls.xoa, 0) = 0 + AND cls.kho IN ('XN', 'HA', 'CN') + GROUP BY cls.kho, cls.maloai, loaicls.tenloai + ORDER BY cls.kho, count(*) DESC; + `; + + // E. Top 10 chẩn đoán + const sqlTop10 = ` + SELECT maicd, kqcdoan, COUNT(*) AS sl + FROM current.khambenh + WHERE ngaykcb BETWEEN $1 AND $2 ${fnNone} + AND COALESCE(xoa, 0) = 0 + AND maicd IS NOT NULL AND maicd != '' + GROUP BY maicd, kqcdoan + ORDER BY sl DESC + LIMIT 10; + `; + + // F. Tiêm ngừa + const sqlTiemNgua = ` + SELECT COUNT(kb.makb) AS sl + FROM current.khambenh kb + LEFT JOIN current.dmphong ph ON kb.maphong = ph.maphong + LEFT JOIN current.dmdonvi dv ON kb.madv = dv.madv + WHERE kb.ngaykcb BETWEEN $1 AND $2 ${fnKb} + AND COALESCE(kb.xoa, 0) = 0 + AND COALESCE(kb.dakham, 0) != 0 + AND ( + COALESCE(ph.loai_bc_giaoban, 0) = 2 OR + ph.maphong = 'TN' OR + dv.madv = 'PTN' OR + ph.tenphong ILIKE '%Tiêm ngừa%' OR + ph.tenphong ILIKE '%Tiêm chủng%' + ); + `; + + const [resKhoa, resBANT, resYDCT, resCLS, resTop10, resTiemNgua] = await Promise.all([ + db.query(sqlKhoa, params, database), + db.query(sqlBANT, params, database), + db.query(sqlYDCT, params, database), + db.query(sqlCLS, params, database), + db.query(sqlTop10, params, database), + db.query(sqlTiemNgua, params, database), + ]); + + return { + khoa: resKhoa.rows, + bant: resBANT.rows[0] || { moi: 0, taikham: 0, tong: 0 }, + ydct: resYDCT.rows[0] || { dien_cham: 0, vltl: 0, sac_thuoc: 0, cay_chi: 0 }, + cls: resCLS.rows, + top10: resTop10.rows, + tiemngua: parseInt(resTiemNgua.rows[0]?.sl || 0, 10), + }; +} + +// 2. API Báo cáo Giao ban Ngoại trú +router.get('/api/giao-ban-ngoai-tru', async (req, res) => { + try { + const { database, from, to, compareFrom, compareTo } = req.query; + const dbName = database || process.env.PG_DATABASE; + const fromDate = from || '2026-03-01 00:00:00'; + const toDate = to || '2026-03-31 23:59:59'; + + const currentData = await getOutpatientData(dbName, fromDate, toDate); + + let compareData = null; + if (compareFrom && compareTo) { + compareData = await getOutpatientData(dbName, compareFrom, compareTo); + } + + res.json({ + success: true, + data: { + current: currentData, + compare: compareData, + periodCurrent: { from: fromDate, to: toDate }, + periodCompare: compareFrom && compareTo ? { from: compareFrom, to: compareTo } : null, + }, + }); + } catch (error) { + console.error('Error in /api/giao-ban-ngoai-tru:', error); + res.status(500).json({ success: false, message: error.message }); + } +}); + +// Helper for Inpatient query +async function getInpatientData(database, fromDate, toDate) { + const params = [fromDate, toDate]; + + const sqlKhoa = ` + SELECT + dv.madv, + dv.tendv, + -- 1. Bệnh cũ (tồn đầu kỳ) + SUM(CASE WHEN nt.ngayvv < $1 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) AS benh_cu, + -- 2. Bệnh mới (vào viện trong kỳ) + SUM(CASE WHEN nt.ngayvv BETWEEN $1 AND $2 THEN 1 ELSE 0 END) AS benh_moi, + -- 3. Xuất viện: + SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND nt.ravien = 1 AND COALESCE(nt.makq, '01') NOT IN ('04','05','06') THEN 1 ELSE 0 END) AS on_xin_ve, + SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.makq IN ('04','06') OR (nt.maxt = '09' AND nt.makq = '04')) THEN 1 ELSE 0 END) AS nang_xin_ve, + SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 2 OR nt.maxt = '08') THEN 1 ELSE 0 END) AS tron_vien, + SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 4 OR nt.maxt = '03') THEN 1 ELSE 0 END) AS chuyen_vien, + SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 3 OR nt.maxt = '06') THEN 1 ELSE 0 END) AS tu_vong, + -- 4. Bệnh hiện còn (tồn cuối kỳ) + SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv > $2) THEN 1 ELSE 0 END) AS benh_hien_con, + -- 5. Bệnh theo dõi nặng + SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) AND (COALESCE(nt.benhnang, 0) = 1 OR COALESCE(nt.mabatheodoi, '') != '' OR dv.madv IN ('17','29')) THEN 1 ELSE 0 END) AS benh_theo_doi, + -- 6. Chăm sóc Cấp I + SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) AND (nt.chamsoc ILIKE '%cấp I%' OR nt.chamsoc ILIKE '%cấp 1%' OR nt.chamsoc IN ('1', 'CS I', 'CSI')) THEN 1 ELSE 0 END) AS chamsoc_cap1 + FROM current.bnnoitru nt + JOIN current.dmdonvi dv ON nt.madv = dv.madv + WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0 + AND nt.ngayvv >= ($1::date - INTERVAL '180 days') + GROUP BY dv.madv, dv.tendv + HAVING SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) > 0 + ORDER BY benh_moi DESC; + `; + + const resKhoa = await db.query(sqlKhoa, params, database); + return { + khoa: resKhoa.rows, + }; +} + +// 3. API Báo cáo Giao ban Nội trú +router.get('/api/giao-ban-noi-tru', async (req, res) => { + try { + const { database, from, to, compareFrom, compareTo } = req.query; + const dbName = database || process.env.PG_DATABASE; + const fromDate = from || '2026-03-01 00:00:00'; + const toDate = to || '2026-03-31 23:59:59'; + + await ensureTable(dbName); + const currentData = await getInpatientData(dbName, fromDate, toDate); + + let compareData = null; + if (compareFrom && compareTo) { + compareData = await getInpatientData(dbName, compareFrom, compareTo); + } + + res.json({ + success: true, + data: { + current: currentData, + compare: compareData, + periodCurrent: { from: fromDate, to: toDate }, + periodCompare: compareFrom && compareTo ? { from: compareFrom, to: compareTo } : null, + }, + }); + } catch (error) { + console.error('Error in /api/giao-ban-noi-tru:', error); + res.status(500).json({ success: false, message: error.message }); + } +}); + +// 4. API Bảng 3: Danh sách Bệnh nhân Chuyển viện +router.get('/api/danh-sach-chuyen-vien', async (req, res) => { + try { + const { database, from, to, madv } = req.query; + const dbName = database || process.env.PG_DATABASE; + const fromDate = from || '2026-03-01 00:00:00'; + const toDate = to || '2026-03-31 23:59:59'; + + await ensureTable(dbName); + + let sqlWhere = ` + WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0 + AND nt.ngayrv BETWEEN $1 AND $2 + AND (nt.ravien = 4 OR nt.maxt = '03') + `; + const params = [fromDate, toDate]; + if (madv && madv !== 'ALL') { + params.push(madv); + sqlWhere += ` AND nt.madv = $${params.length} `; + } + + const sql = ` + SELECT + nt.mabn, + nt.maba, + bn.holot || ' ' || bn.ten AS hoten, + bn.diachi, + to_char(nt.ngayvv, 'DD/MM/YYYY HH24:MI') AS ngayvv, + to_char(nt.ngayrv, 'DD/MM/YYYY HH24:MI') AS ngayrv, + COALESCE(dv.tendv, nt.madv) AS tendv, + nt.kqcdoan AS chandoan_chinh, + nt.kqcdoanp AS chandoan_phu, + COALESCE(cls_sub.ketqua_cls, 'Chưa có kết quả') AS ketqua_cls, + COALESCE(gb.ghichu, nt.ghichu, '') AS ghichu + FROM current.bnnoitru nt + JOIN current.dmbenhnhan bn ON nt.mabn = bn.mabn + LEFT JOIN current.dmdonvi dv ON nt.madv = dv.madv + LEFT JOIN current.giaoban_ghichu gb ON nt.maba = gb.maba AND gb.loai_danhsach = 'CHUYEN_VIEN' + LEFT JOIN LATERAL ( + SELECT string_agg(DISTINCT cls.tencls || COALESCE(': ' || NULLIF(ha.ketluan,''), ''), '; ') AS ketqua_cls + FROM current.chidinhcls ls + JOIN current.dmcls cls ON ls.macls = cls.macls + LEFT JOIN current.pskhamha ha ON ls.mabn = ha.mabn AND ls.macls = ha.macls AND ls.makb = ha.makb + WHERE ls.mabn = nt.mabn AND ls.makb = nt.makb AND COALESCE(ls.xoa, 0) = 0 + ) cls_sub ON true + ${sqlWhere} + ORDER BY nt.ngayrv DESC + LIMIT 100; + `; + + const result = await db.query(sql, params, dbName); + const cleanedRows = result.rows.map(row => ({ + ...row, + ketqua_cls: db.cleanRtf(row.ketqua_cls) + })); + + res.json({ success: true, data: cleanedRows }); + } catch (error) { + console.error('Error in /api/danh-sach-chuyen-vien:', error); + res.status(500).json({ success: false, message: error.message }); + } +}); + +// 5. API Bảng 4: Danh sách Bệnh nhân Theo dõi & Chăm sóc Cấp I +router.get('/api/danh-sach-theo-doi', async (req, res) => { + try { + const { database, from, to, madv } = req.query; + const dbName = database || process.env.PG_DATABASE; + const fromDate = from || '2026-03-01 00:00:00'; + const toDate = to || '2026-03-31 23:59:59'; + + await ensureTable(dbName); + + let sqlWhere = ` + WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0 + AND (nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1)) + AND ( + COALESCE(nt.benhnang, 0) = 1 OR + COALESCE(nt.mabatheodoi, '') != '' OR + nt.chamsoc ILIKE '%cấp I%' OR + nt.chamsoc ILIKE '%cấp 1%' OR + nt.chamsoc IN ('1', 'CS I', 'CSI') OR + nt.madv IN ('17', '29') + ) + `; + const params = [fromDate, toDate]; + if (madv && madv !== 'ALL') { + params.push(madv); + sqlWhere += ` AND nt.madv = $${params.length} `; + } + + const sql = ` + SELECT + nt.mabn, + nt.maba, + bn.holot || ' ' || bn.ten AS hoten, + bn.diachi, + to_char(nt.ngayvv, 'DD/MM/YYYY HH24:MI') AS ngayvv, + to_char(nt.ngayrv, 'DD/MM/YYYY HH24:MI') AS ngayrv, + COALESCE(dv.tendv, nt.madv) AS tendv, + nt.kqcdoan AS chandoan_chinh, + nt.kqcdoanp AS chandoan_phu, + COALESCE(nt.chamsoc, 'Theo dõi đặc biệt') AS chamsoc, + COALESCE(cls_sub.ketqua_cls, 'Chưa có kết quả') AS ketqua_cls, + COALESCE(gb.ghichu, nt.ghichu, '') AS ghichu + FROM current.bnnoitru nt + JOIN current.dmbenhnhan bn ON nt.mabn = bn.mabn + LEFT JOIN current.dmdonvi dv ON nt.madv = dv.madv + LEFT JOIN current.giaoban_ghichu gb ON nt.maba = gb.maba AND gb.loai_danhsach = 'THEO_DOI' + LEFT JOIN LATERAL ( + SELECT string_agg(DISTINCT cls.tencls || COALESCE(': ' || NULLIF(ha.ketluan,''), ''), '; ') AS ketqua_cls + FROM current.chidinhcls ls + JOIN current.dmcls cls ON ls.macls = cls.macls + LEFT JOIN current.pskhamha ha ON ls.mabn = ha.mabn AND ls.macls = ha.macls AND ls.makb = ha.makb + WHERE ls.mabn = nt.mabn AND ls.makb = nt.makb AND COALESCE(ls.xoa, 0) = 0 + ) cls_sub ON true + ${sqlWhere} + ORDER BY nt.ngayvv DESC + LIMIT 100; + `; + + const result = await db.query(sql, params, dbName); + const cleanedRows = result.rows.map(row => ({ + ...row, + ketqua_cls: db.cleanRtf(row.ketqua_cls) + })); + + res.json({ success: true, data: cleanedRows }); + } catch (error) { + console.error('Error in /api/danh-sach-theo-doi:', error); + res.status(500).json({ success: false, message: error.message }); + } +}); + +// 6. API Cập nhật Ghi chú trực tiếp từ lưới (Bảng 3 & Bảng 4) +router.post('/api/cap-nhat-ghi-chu', async (req, res) => { + try { + const { maba, mabn, loai, ghichu, database } = req.body; + if (!maba || !loai) { + return res.status(400).json({ success: false, message: 'Thiếu maba hoặc loại danh sách' }); + } + const dbName = database || process.env.PG_DATABASE; + await ensureTable(dbName); + + const sqlUpsert = ` + INSERT INTO current.giaoban_ghichu (maba, mabn, loai_danhsach, ghichu, ngay_capnhat) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (maba, loai_danhsach) + DO UPDATE SET ghichu = EXCLUDED.ghichu, ngay_capnhat = NOW(); + `; + await db.query(sqlUpsert, [maba, mabn || '', loai, ghichu || ''], dbName); + + // Đồng bộ vào bnnoitru.ghichu nếu cần + try { + await db.query(`UPDATE current.bnnoitru SET ghichu = $1 WHERE maba = $2;`, [ghichu || '', maba], dbName); + } catch (e) { + // ignore + } + + res.json({ success: true, message: 'Đã lưu ghi chú thành công!' }); + } catch (error) { + console.error('Error in /api/cap-nhat-ghi-chu:', error); + res.status(500).json({ success: false, message: error.message }); + } +}); + +// 7. API Quản lý Issue & Báo lỗi (lưu file và tổng hợp) +router.get('/api/issues', (req, res) => { + try { + const list = issueService.getAllIssues(); + res.json({ success: true, data: list }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}); + +router.post('/api/issues', upload.single('attachment'), (req, res) => { + try { + const issue = issueService.createIssue(req.body, req.file); + res.json({ success: true, data: issue, message: 'Đã ghi nhận báo lỗi thành công!' }); + } catch (error) { + console.error('Error creating issue:', error); + res.status(500).json({ success: false, message: error.message }); + } +}); + +router.put('/api/issues/:id', (req, res) => { + try { + const updated = issueService.updateIssue(req.params.id, req.body); + res.json({ success: true, data: updated, message: 'Cập nhật trạng thái issue thành công!' }); + } catch (error) { + console.error('Error updating issue:', error); + res.status(500).json({ success: false, message: error.message }); + } +}); + +router.delete('/api/issues/:id', (req, res) => { + try { + issueService.deleteIssue(req.params.id); + res.json({ success: true, message: 'Đã xóa issue thành công!' }); + } catch (error) { + console.error('Error deleting issue:', error); + res.status(500).json({ success: false, message: error.message }); + } +}); + +// Mount router on both /web-giao-ban and / +app.use('/web-giao-ban', router); +app.use('/', router); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`=======================================================`); + console.log(`[DH-HIS] Phần mềm Quản lý Giao ban hằng ngày (Node.js)`); + console.log(`Server is running at: http://localhost:${PORT}`); + console.log(`=======================================================`); +}); diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..f1d7008 --- /dev/null +++ b/start.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Script khởi động dịch vụ web-giao-ban +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +cd "$DIR" + +if systemctl is-enabled web-giao-ban.service >/dev/null 2>&1; then + echo "Starting web-giao-ban via systemd..." + sudo systemctl start web-giao-ban + sleep 1 + sudo systemctl status web-giao-ban --no-pager +else + PID_FILE="$DIR/.app.pid" + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "web-giao-ban is already running (PID: $(cat "$PID_FILE"))" + exit 0 + fi + echo "Starting web-giao-ban in background..." + nohup /usr/bin/node server.js > app.log 2> error.log & + echo $! > "$PID_FILE" + echo "Started with PID: $!" +fi diff --git a/status.sh b/status.sh new file mode 100755 index 0000000..d673679 --- /dev/null +++ b/status.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Script kiểm tra trạng thái web-giao-ban +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +cd "$DIR" + +echo "=== TRẠNG THÁI DỊCH VỤ WEB-GIAO-BAN ===" +if systemctl is-active web-giao-ban.service >/dev/null 2>&1; then + sudo systemctl status web-giao-ban --no-pager +else + PID_FILE="$DIR/.app.pid" + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "Status: RUNNING (PID: $(cat "$PID_FILE"))" + else + echo "Status: STOPPED" + fi +fi + +echo "" +echo "=== KIỂM TRA KẾT NỐI PORT 3000 ===" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/web-giao-ban/ || echo "ERR") +echo "HTTP Status: $HTTP_CODE (http://127.0.0.1:3000/web-giao-ban/)" + +if [ "$HTTP_CODE" == "200" ]; then + echo "Dịch vụ web-giao-ban đang hoạt động tốt!" +else + echo "Cảnh báo: Không thể truy cập cổng 3000 hoặc mã trả về không phải 200." +fi diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..f97f050 --- /dev/null +++ b/stop.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Script dừng dịch vụ web-giao-ban +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +cd "$DIR" + +if systemctl is-active web-giao-ban.service >/dev/null 2>&1; then + echo "Stopping web-giao-ban via systemd..." + sudo systemctl stop web-giao-ban + echo "Stopped." +else + PID_FILE="$DIR/.app.pid" + if [ -f "$PID_FILE" ]; then + PID="$(cat "$PID_FILE")" + if kill -0 "$PID" 2>/dev/null; then + echo "Stopping web-giao-ban (PID: $PID)..." + kill "$PID" 2>/dev/null || true + sleep 1 + fi + rm -f "$PID_FILE" + echo "Stopped." + else + # Kill by port 3000 if running + PID=$(lsof -t -i:3000 2>/dev/null || fuser 3000/tcp 2>/dev/null) + if [ -n "$PID" ]; then + echo "Killing process on port 3000 (PID: $PID)..." + kill -9 $PID 2>/dev/null || true + echo "Stopped." + else + echo "web-giao-ban is not running." + fi + fi +fi diff --git a/uploads/issues/.gitkeep b/uploads/issues/.gitkeep new file mode 100644 index 0000000..e69de29