commit f73ac7b5bb657a6c6088ea131167396e2b5063f5 Author: ongtrieuhau Date: Mon Oct 26 00:48:05 2026 -0700 feat: DH-HIS Web Giao Ban (Node.js & Express) complete codebase 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 0000000..9ab3164 Binary files /dev/null and b/public/favicon.ico differ 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