您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Hỗ trợ dữ liệu bệnh nhân từ bs-noitru.tahospital.vn.
// ==UserScript== // @name BS Nội trú - Helper (TA Hospital) - By drquochoai, BS.CKI Trần Quốc Hoài // @namespace http://tampermonkey.net/ // @version 1.8.7 // @description Hỗ trợ dữ liệu bệnh nhân từ bs-noitru.tahospital.vn. // @author BS.CKI Trần Quốc Hoài, tahospital.vn // @match https://bs-noitru.tahospital.vn/* // @match https://dd-noitru.tahospital.vn/* // @match https://hsba.tahospital.vn/* // @match https://otm.tahospital.vn/* // @grant GM_xmlhttpRequest // @grant GM_openInTab // @grant GM_setValue // @grant GM_getValue // @grant GM_addValueChangeListener // @license MIT // @connect google.com // @connect tahospital.vn // @connect bs-noitru.tahospital.vn // @connect script.google.com // @connect googleusercontent.com // @sandbox MAIN_WORLD // ==/UserScript== (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){ // BS_CAI_DAT_GIAO_DIEN.js // File cấu hình giao diện cho hệ thống Bệnh Sử Nội Trú // Có thể chỉnh sửa các cài đặt này để tùy chỉnh giao diện const BS_CAI_DAT = { // ================== CÀI ĐẶT HSBA ================== // Quy tắc xử lý tài liệu HSBA. // - tenmau: Tên mẫu tài liệu gốc từ HSBA V2 // - show: true nếu muốn hiển thị trong danh sách "dr-hsba-item" // - sync: true nếu muốn dùng tài liệu này để đồng bộ với checklist bộ mổ // - checklist: (tùy chọn) Nhãn checklist mục tiêu khi sync === true // Lưu ý: Một mục có thể chỉ show (hiển thị) hoặc chỉ sync (đồng bộ) hoặc cả hai. HSBA_CHECKLIST_MAP: [ // Hiển thị + Đồng bộ vào checklist { tenmau: 'Phiếu khám bệnh vào viện', show: true, sync: true, checklist: 'Phiếu Khám vào viện (hsoft)' }, { tenmau: 'Biên bản hội chẩn duyệt mổ', show: true, sync: true, checklist: 'Tạo Biên bản Hội chẩn duyệt mổ (web)' }, { tenmau: 'Phiếu cung cấp thông tin chẩn đoán, điều trị và chi phí', show: true, sync: true, checklist: 'Phiếu cung cấp thông tin, chẩn đoán và điều trị (hsoft)' }, { tenmau: 'Giấy cam đoan thực hiện Phẫu thuật, Thủ thuật và Gây mê hồi sức', show: true, sync: true, checklist: '57. Cam kết phẫu thuật thủ thuật (hsoft)' }, { tenmau: 'Phiếu khai thác tiền sử dị ứng', show: true, sync: true, checklist: 'Phiếu khai thác tiền sử dị ứng (hsoft)' }, { tenmau: 'Phiếu HKTT trên bệnh người Phẫu thuật', show: false, sync: true, checklist: 'Đánh giá nguy cơ huyết khối (web)' }, { tenmau: 'Phiếu khám tiền mê', show: true, sync: true, checklist: 'ĐÃ khám tiền mê CHƯA?' }, // Chỉ hiển thị (không sync checklist) { tenmau: 'Phiếu khám chuyên khoa', show: true, sync: false }, { tenmau: 'Phiếu tường trình phẫu thuật, thủ thuật', show: true, sync: false }, { tenmau: 'Phiếu khám bệnh', show: true, sync: false }, { tenmau: 'Toa thuốc ngoại trú', show: true, sync: false }, // Không hiển thị (chỉ sync checklist) { tenmau: 'Phiếu Theo dõi điều trị', show: true, sync: true, checklist: 'Tờ điều trị (web)' }, // Có thể bổ sung thêm nếu cần ], // ================== CÀI ĐẶT CHECKLIST ================== checklistItems: [ 'Phiếu Khám vào viện (hsoft)', 'Bệnh án Ngoại khoa', 'Tờ điều trị (web)', 'Tạo Biên bản Hội chẩn duyệt mổ (web)', 'Phiếu khai thác tiền sử dị ứng (hsoft)', '57. Cam kết phẫu thuật thủ thuật (hsoft)', 'Phiếu cung cấp thông tin, chẩn đoán và điều trị (hsoft)', 'Đánh giá nguy cơ huyết khối (web)', 'Chuyển xét nghiệm vào khoa (hsoft) và ✅ ký số', 'Đánh dấu vết mổ', 'ĐÃ khám tiền mê CHƯA?', 'ĐÃ đặt lịch mổ CHƯA?', 'ĐÃ ghi y lệnh chuyển mổ', 'Phiếu kiểm tra HIV test (hsoft)', ], // ================== CÀI ĐẶT CHECKLIST XUẤT VIỆN ================== checklistXuatVien: [ 'Mở HSBA v2', 'Nhập khoa (chỉnh chẩn đoán, ICD)', 'Mở trang dặn dò', 'Giấy ra viện', 'Tóm tắt bệnh án', // Tờ điều trị sẽ có checklist con { label: 'Tờ điều trị', children: [ 'Thực hiện y lệnh thuốc đã dự trù', 'Trả thuốc cử chiều & tối', 'Toa thuốc ra viện', 'Chuyển dược, In toa', 'Tổng kết bệnh án trong tờ điều trị', 'Tổng kết bệnh án điện tử', 'Ký số các CLS tồn' ] } ], // ================== CÀI ĐẶT Y LỆNH QUICK ACTIONS ================== quickYLenhActions: [ { label: 'Xuất viện', icon: '🏠', color: '#4caf50' }, { label: 'Cận lâm sàng', icon: '🧪', color: '#06b6d4' }, { label: 'Đã đánh thuốc', icon: '💊', color: '#16a34a' }, { label: 'Thay băng', icon: '👗', color: '#310994ff' }, { label: 'Rút ODL vết mổ', icon: '🩹', color: '#ff9800' }, { label: 'Rút ODL phổi', icon: '🫁', color: '#2196f3' }, { label: 'Rút sonde tiểu', icon: '🔗', color: '#9c27b0' } ], // ================== CÀI ĐẶT BÁC SĨ ================== danhSachBacSi: [ 'BS Dũng', 'BS Quyền', 'BS Hằng', 'BS Hoài', 'BS Hiếu', 'BS Hải', 'BS Hưng' ], // ================== CÀI ĐẶT PHẪU THUẬT ================== phauThuatDefaults: { defaultDate: 'tomorrow', // 'tomorrow' | 'today' | null defaultTime: '07:30', }, // ================== CÀI ĐẶT MÀU SẮC VÀ GIAO DIỆN ================== colors: { // Màu chính primary: '#1976d2', secondary: '#4caf50', danger: '#d32f2f', warning: '#ff9800', info: '#2196f3', // Màu cho thẻ bệnh nhân cardBackground: '#fff', cardBorder: '#e0e0e0', blueCardBackground: '#e3f2fd', // Màu cho tags y lệnh tagDefault: '#4caf50', tagBackground: 'rgba(76, 175, 80, 0.1)', tagBorder: 'rgba(76, 175, 80, 0.3)', }, // ================== CÀI ĐẶT TAGS ================== tags: { maxDisplayTags: 3, // Số lượng tags tối đa hiển thị trên mỗi thẻ bệnh nhân showOnlyToday: true, // Chỉ hiển thị y lệnh hôm nay defaultIcon: '📋', // Cài đặt cho celebration (khi có tag Xuất viện) celebration: { enabled: true, // Bật/tắt hiệu ứng celebration glowColor: '#4caf50', // Màu glow animation borderWidth: '3px', // Độ dày viền celebration animationDuration: '2s', // Thời gian animation } }, // ================== CÀI ĐẶT THỜI GIAN ================== timing: { autoSaveDelay: 500, // ms - thời gian delay khi auto-save loadChecklistDelay: 100, // ms - thời gian delay khi load checklist eventListenerDelay: 10, // ms - thời gian delay khi setup event listeners }, // ================== CÀI ĐẶT DEBUG ================== debug: { enableLogging: false, // Bật/tắt console.log enableTagsTest: true, // Bật/tắt function test tags defaultTestMabn: '2510149440', // Mã bệnh nhân mặc định để test }, // ================== CÀI ĐẶT TEXT ================== text: { noYLenhMessage: 'Chưa có y lệnh nào...', noPhauThuatMessage: 'Chưa có phẫu thuật nào...', loadingMessage: 'Đang tải checklist...', noDataMessage: 'Không có dữ liệu', errorMessage: 'Lỗi tải checklist', // Tiêu đề các phần checklistTitle: 'Checklist bộ mổ', phauThuatTitle: 'Thông tin phẫu thuật', yLenhTitle: 'Log y lệnh', // Button text addPhauThuatBtn: 'Thêm phẫu thuật', addYLenhBtn: 'Thêm', saveBtn: 'Lưu', cancelBtn: 'Hủy', deleteBtn: 'Xóa', // Placeholder text yLenhPlaceholder: 'Nhập y lệnh (VD: rút sonde tiểu)', ppptPlaceholder: 'Nhập PPPT', datePlaceholder: 'dd/mm/yyyy', }, // ================== CÀI ĐẶT VALIDATION ================== validation: { requiredFields: { phauThuat: ['date', 'time', 'method', 'doctors'], yLenh: ['content'] }, dateFormat: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/, messages: { missingPhauThuatInfo: 'Vui lòng nhập đầy đủ thông tin phẫu thuật!', invalidDateFormat: 'Vui lòng nhập ngày theo định dạng dd/mm/yyyy!', invalidMonth: 'Tháng không hợp lệ (1-12)!', invalidDay: 'Ngày không hợp lệ (1-31)!', invalidDate: 'Ngày không tồn tại!', noDoctorSelected: 'Vui lòng chọn ít nhất một bác sĩ!', } }, // ================== CÀI ĐẶT LAYOUT ================== layout: { sidebar: { width: '80vw', maxWidth: '80vw', zIndex: 100000, }, popup: { maxWidth: '500px', width: '90vw', maxHeight: '80vh', zIndex: 100001, }, cards: { gap: '16px', borderRadius: '8px', padding: '16px', } } }; // Export cho Node.js environment (browserify) if (typeof module !== 'undefined' && module.exports) { module.exports = BS_CAI_DAT; } // Export cho browser environment if (typeof window !== 'undefined') { window.BS_CAI_DAT = BS_CAI_DAT; } },{}],2:[function(require,module,exports){ // DanhSachBenhNhan.js function DanhSachBenhNhan() { this.danhSach = null; this.lastFetched = null; this.autoFetchTimer = null; this.fixedMabn = "51991h991h991h991"; } DanhSachBenhNhan.prototype.layDanhSachTheoMaBNFixed = function() { var self = this; return this._fetchDanhSach(this.fixedMabn).then(function(data) { if (data && data.length > 0) { self.danhSach = data; self.lastFetched = new Date(); console.log('Đã lấy danh sách cho MABN cố định (' + data.length + ' mục).'); self.uploadChecklistWithDrData(self.fixedMabn); } else { self.uploadChecklistWithDrData(self.fixedMabn); } return data; }); }; DanhSachBenhNhan.prototype.luuDanhSachBenhNhanVoiNgayVVMacDinh = function(ds) { if (!Array.isArray(ds)) return; this.danhSach = ds.map(function(bn) { var copy = {}; for (var k in bn) copy[k] = bn[k]; copy.ngayvv = "01/01/1001 01:01"; return copy; }); this.lastFetched = new Date(); alert('Đã lưu ' + this.danhSach.length + ' bệnh nhân với ngày vào viện mặc định.'); }; DanhSachBenhNhan.prototype.startAutoFetch = function() { var self = this; if (this.autoFetchTimer) clearInterval(this.autoFetchTimer); this.autoFetchTimer = setInterval(function() { self._autoFetch7h(); }, 60000); }; DanhSachBenhNhan.prototype._autoFetch7h = function() { var now = new Date(); if (now.getHours() === 7 && now.getMinutes() === 0) { if (this.lastFetched) { var last = new Date(this.lastFetched); if (last.getFullYear() === now.getFullYear() && last.getMonth() === now.getMonth() && last.getDate() === now.getDate() && last.getHours() === 7) { alert('Đã có danh sách bệnh nhân lúc 7h sáng rồi.'); return; } } this.layDanhSachTheoMaBNFixed(); } }; DanhSachBenhNhan.prototype.addFetchButtonToBottomBar = function() { var self = this; function addBtn() { var bar = document.querySelector('.dr-bottom-bar'); if (!bar || bar.querySelector('#btn-fetch-fixed-mabn')) return; var btn = document.createElement('button'); btn.id = 'btn-fetch-fixed-mabn'; btn.innerText = 'Lấy DSBN (MABN cố định)'; btn.className = 'btn btn-info'; btn.style.marginLeft = '12px'; btn.onclick = function() { self.layDanhSachTheoMaBNFixed(); }; bar.appendChild(btn); } addBtn(); document.addEventListener('DOMContentLoaded', addBtn); setTimeout(addBtn, 2000); }; DanhSachBenhNhan.prototype._fetchDanhSach = function(mabn) { var self = this; return new Promise(function(resolve) { var formData = new FormData(); formData.append('mabn', mabn); var now = new Date(); var month = String(now.getMonth() + 1).padStart(2, '0'); var day = String(now.getDate()).padStart(2, '0'); var year = now.getFullYear(); var dateStr = month + '/' + day + '/' + year + ' 07:00'; formData.append('tungay', "01/01/1001 01:01"); formData.append('denngay', "01/01/3001 01:01"); fetch('/DanhSachBenhNhan/DSPhieuCCThongTinVaCamKetNhapVien', { method: 'POST', credentials: 'include', body: formData }).then(function(r) { return r.json(); }).then(function(res) { if (res && res.data) resolve(res.data); else { if (window.DanhSachBenhNhanManager && typeof window.DanhSachBenhNhanManager.uploadChecklistWithDrData === 'function') { window.DanhSachBenhNhanManager.uploadChecklistWithDrData(mabn, function(uploadRes) { resolve(null); }); } else { resolve(null); } } }).catch(function() { resolve(null); }); }); }; DanhSachBenhNhan.prototype.uploadChecklistWithDrData = function(mabn, callback) { var formData = new FormData(); formData.append('status', '1'); formData.append('thebaohiemyte', 'Không'); formData.append('chuky', JSON.stringify(window.dr_data || {})); formData.append('khac', '--*--'); formData.append('khu', '1'); formData.append('mabn', mabn); formData.append('bieumauid', '027'); try { const { getSelectedKhoa } = require('./utils/khoaUtils'); formData.append('makp', getSelectedKhoa('551')); } catch(_) { formData.append('makp', '551'); } formData.append('__model', 'TAH.Entity.Model.PHIEUCCTHONGTINVACAMKETNHAPVIEN.ERM_PHIEUCCTHONGTINVACAMKETNHAPVIEN'); formData.append('actiontype', ''); formData.append('hoten', 'Không rõ%'); formData.append('ngaysinh', '10/10/1999'); formData.append('gioitinh', 'Nam'); const now = new Date(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const year = now.getFullYear(); formData.append('maql', `${month}/${day}/${year} 07:00`); fetch('/ERM_PHIEUCCTHONGTINVACAMKETNHAPVIEN/CreateAjax', { method: 'POST', credentials: 'include', body: formData }).then(function(r) { return r.json(); }).then(function(res) { if (typeof callback === 'function') callback(res); }).catch(function() { if (typeof callback === 'function') callback(null); }); }; module.exports = DanhSachBenhNhan; },{"./utils/khoaUtils":39}],3:[function(require,module,exports){ // Global function to open HSBA V2 - Define at top level for global access // This needs to be outside any function to be truly global // Don't use window.openHSBAV2 as it may not work in Tampermonkey async function openHSBAV2(mabn) { try { const response = await fetch('/ToDieuTri/LoadLinkHsba', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'include', body: 'code=' + encodeURIComponent(mabn) }); const result = await response.json(); if (result && result.data && result.data.link) { window.open(result.data.link, '_blank'); } else { console.error('Không lấy được link HSBA V2'); alert('Không lấy được link HSBA V2'); } } catch (error) { console.error('Lỗi khi load link HSBA V2:', error); alert('Lỗi khi load link HSBA V2'); } } // Also assign to window as fallback but the function declaration above should work if (typeof window !== 'undefined') { window.openHSBAV2 = openHSBAV2; } // Make it available in global scope for Tampermonkey this.openHSBAV2 = openHSBAV2; unsafeWindow.openHSBAV2 = openHSBAV2; (function () { 'use strict'; const Utils = require('./utils'); // Ensure HSBA background worker runs on hsba.tahospital.vn when this bundle is injected there try { require('./components/hsbaDataFetcher'); } catch(_) {} // Ensure OTM entry runs on otm.tahospital.vn when this bundle is injected there try { require('./pages/otm-entry'); } catch(_) {} const DanhSachBenhNhan = require('./DanhSachBenhNhan'); const { GoogleAppsScriptUploader, GOOGLE_APPS_SCRIPT_URL } = require('./googleAppsScript'); const { showDashboardBenhNhanIfNeeded } = require('./pages/page.dashboard'); const { showSettingsIfNeeded } = require('./pages/page.settings'); // Lịch mổ hôm nay route hook try { const { showLichMoHomNayIfNeeded } = require('./pages/page.lichmo.homnay'); showLichMoHomNayIfNeeded(); } catch(_) {} const { initCopyDienTienAI } = require('./components/copyDienTienAI'); const ChecklistService = require('./services/checklistService'); showDashboardBenhNhanIfNeeded(); showSettingsIfNeeded(); try { window.addEventListener('online', () => { try { ChecklistService.drainSaveQueue(); } catch(_) {} }); } catch(_) {} // --- Khởi tạo class và gắn vào window để dễ test --- window.DanhSachBenhNhanManager = new DanhSachBenhNhan(); window.DanhSachBenhNhanManager.startAutoFetch(); window.DanhSachBenhNhanManager.addFetchButtonToBottomBar(); // --- Khởi tạo uploader Google Apps Script và gắn vào window để có thể dùng ở nơi khác nếu cần --- var uploader = new GoogleAppsScriptUploader(GOOGLE_APPS_SCRIPT_URL); window.GoogleAppsScriptUploader = uploader; // uploader.addUploadButton(); // ĐÃ XÓA toàn bộ định nghĩa các hàm createDirectReportGeneration, fetchToDieuTriData, addGlobalStyles, updateChecklistPhieu, createChecklistPhieu khỏi file này vì đã chuyển sang dashboard.support.js và được sử dụng qua dashboard.js // Show dr_data as cards if ?show=true or ?nln in URL // ĐÃ XÓA toàn bộ định nghĩa hàm showDashboardBenhNhanIfNeeded và các hàm con bên trong (từ dòng 192 trở đi cho đến hết hàm) // ĐÃ XÓA đoạn kiểm tra window.dr_data, fetchToDieuTriData và renderCards khỏi file này vì đã chuyển sang dashboard.js // Nếu URL kết thúc bằng /to-dieu-tri thì tự động click #cbTaCa nếu tồn tại function autoClickCbTaCaIfNeeded() { // --- Tự động click #cbTaCa nếu ở trang /to-dieu-tri --- if (/\/to-dieu-tri(\?.*)?$/.test(window.location.pathname) || window.location.href.includes('DanhSachBenhNhan')) { $('#ddlKhoa').on('change', function () { const v = $(this).val(); try { localStorage.setItem('bsnt_khoa_dashboard', v); } catch(_) {} }); setTimeout(() => { const savedKhoa = localStorage.getItem('bsnt_khoa_dashboard') || "551"; if (savedKhoa) { $('#ddlKhoa').val(savedKhoa).change(); } console.log('Đã tự động chọn khoa ' + savedKhoa + ' trong dropdown #ddlKhoa'); }, 500); const cb = document.getElementById('cbTaCa'); if (cb) { cb.click(); } } } autoClickCbTaCaIfNeeded(); // Initialize Copy Diễn Tiến button on /to-dieu-tri try { initCopyDienTienAI(); } catch(_) {} // Auto-login on /Home/Login: always fill from default account; only auto-submit if enabled try { const isLoginPage = /\/Home\/Login(\?.*)?$/.test(window.location.pathname); if (isLoginPage && window.localStorage) { const ACC_KEY = 'dr_accounts_json'; const DEF_KEY = 'dr_acc_default'; const AUTO_KEY = 'dr_acc_autologin'; let accounts = []; try { accounts = JSON.parse(localStorage.getItem(ACC_KEY) || '[]'); } catch(_) { accounts = []; } const defUser = localStorage.getItem(DEF_KEY) || ''; const acc = accounts.find(a => (a && a.username) === defUser) || accounts[0] || null; const userInput = document.querySelector('input[name="username"][placeholder="Tên đăng nhập"]'); const passInput = document.querySelector('input[type="password"][name="password"][placeholder="Mật khẩu"]'); const submitBtn = document.querySelector('button[type="submit"].btn.btn-primary.btn-block'); if (acc && userInput && passInput) { userInput.value = acc.username || ''; passInput.value = acc.password || ''; } if (acc && localStorage.getItem(AUTO_KEY) === '1' && userInput && passInput && submitBtn) { setTimeout(() => { submitBtn.click(); setTimeout(() => { try { window.location.href = '/?nln'; } catch(_) {} }, 1500); }, 200); } else if (localStorage.getItem(AUTO_KEY) !== '1') { // Render account picker panel to the right of login card try { const ensurePanel = () => { const loginCard = document.querySelector('div.card.card-outline.card-primary'); if (!loginCard || accounts.length === 0 || document.getElementById('dr-quochoai-danh-sach-tai-khoan-login')) return; // Inject minimal CSS for layout + blue buttons if (!document.getElementById('dr-login-accounts-css')) { const style = document.createElement('style'); style.id = 'dr-login-accounts-css'; style.textContent = ` #dr-quochoai-danh-sach-tai-khoan-login { position: fixed; width: 300px; max-width: 340px; display: flex; flex-direction: column; gap: 8px; background:#fff; border:1px solid #e5e7eb; border-radius:10px; padding:10px; z-index:2147483647; } #dr-quochoai-danh-sach-tai-khoan-login .dr-acc-title { font-weight: 700; margin-bottom: 4px; color: #0d47a1; } #dr-quochoai-danh-sach-tai-khoan-login .dr-acc-btn { background: #1976d2; color: #fff; border: none; padding: 10px 12px; border-radius: 8px; font-weight: 600; cursor: pointer; text-align: left; box-shadow: 0 1px 2px rgba(0,0,0,0.12); } #dr-quochoai-danh-sach-tai-khoan-login .dr-acc-btn:hover { background: #1565c0; } `; document.head && document.head.appendChild(style); } const panel = document.createElement('div'); panel.id = 'dr-quochoai-danh-sach-tai-khoan-login'; const titleEl = document.createElement('div'); titleEl.className = 'dr-acc-title'; titleEl.textContent = 'Tài khoản đã lưu'; panel.appendChild(titleEl); accounts.forEach(a => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'dr-acc-btn'; const title = a.title || a.username || 'Tài khoản'; btn.textContent = title + (a.username ? ` (${a.username})` : ''); btn.addEventListener('click', () => { if (userInput && passInput && submitBtn) { userInput.value = a.username || ''; passInput.value = a.password || ''; submitBtn.click(); setTimeout(() => { try { window.location.href = '/?nln'; } catch(_) {} }, 1500); } }); panel.appendChild(btn); }); // Append panel directly to body and position it to the right of the login card if (document.body && document.body.appendChild) { document.body.appendChild(panel); const positionPanel = () => { const rect = loginCard.getBoundingClientRect(); const panelRect = panel.getBoundingClientRect(); const top = Math.max(12, rect.top + window.scrollY); let left = rect.right + 16 + window.scrollX; const maxLeft = window.scrollX + window.innerWidth - panelRect.width - 12; if (left > maxLeft) left = Math.max(12 + window.scrollX, maxLeft); panel.style.top = top + 'px'; panel.style.left = left + 'px'; }; // Initial and delayed to ensure metrics positionPanel(); setTimeout(positionPanel, 0); window.addEventListener('resize', positionPanel); window.addEventListener('scroll', positionPanel, { passive: true }); } }; // Run now or retry a few times if the DOM isn’t ready yet if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', ensurePanel); } else { ensurePanel(); let tries = 0; const iv = setInterval(() => { tries++; if (document.getElementById('dr-quochoai-danh-sach-tai-khoan-login') || tries > 10) return clearInterval(iv); ensurePanel(); }, 300); } } catch(_) {} } } } catch(_) {} // Gọi hàm khi trang chính load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { addDashboardMenuToSidebar(); addDashboardMenuToTopbar(); addAutoLoginToggleToTopbar(); }); } else { addDashboardMenuToSidebar(); addDashboardMenuToTopbar(); addAutoLoginToggleToTopbar(); } // Thêm menu mở dashboard vào sidebar function addDashboardMenuToSidebar() { // Tìm nav sidebar const nav = document.querySelector('nav.mt-2 ul.nav-sidebar'); if (!nav) return; // Kiểm tra đã có menu chưa if (nav.querySelector('.bsnt-dashboard-menu')) return; // Tạo li mới const li = document.createElement('li'); li.className = 'nav-item bsnt-dashboard-menu'; const a = document.createElement('a'); a.className = 'nav-link'; a.href = '/?nln'; a.target = '_blank'; // Mở trong tab mới a.innerHTML = '<i class="nav-icon fas fa-tachometer-alt"></i> <p>Mở dashboard</p>'; // Style vàng và bo tròn a.style.background = 'gold'; a.style.borderRadius = '12px'; a.style.color = '#333'; a.style.fontWeight = 'bold'; a.onmouseover = function () { a.style.background = '#ffe066'; }; a.onmouseout = function () { a.style.background = 'gold'; }; li.appendChild(a); // Thêm vào đầu ul nav.insertBefore(li, nav.firstChild); } // Thêm nút TỰ ĐỘNG LOGIN vào TOPBAR bên trái <li class="nav-item dropdown"> function addAutoLoginToggleToTopbar() { const { createAutoLoginToggle, applyToggleStyles } = require('./components/autoLoginToggle'); const dropdownLi = document.querySelector('nav.main-header ul.navbar-nav li.nav-item.dropdown') || document.querySelector('ul.navbar-nav li.nav-item.dropdown') || document.querySelector('li.nav-item.dropdown'); if (!dropdownLi) return; const ul = dropdownLi.parentElement; if (!ul) return; if (ul.querySelector('.bsnt-autologin-toggle')) return; const li = document.createElement('li'); li.className = 'nav-item bsnt-autologin-toggle'; const enabled = window.localStorage && window.localStorage.getItem('dr_acc_autologin') === '1'; const a = createAutoLoginToggle({ enabled, onToggle: () => { const cur = window.localStorage && window.localStorage.getItem('dr_acc_autologin') === '1'; if (window.localStorage) window.localStorage.setItem('dr_acc_autologin', cur ? '0' : '1'); applyToggleStyles(a, !cur); }, onDblClick: () => window.open('/?caidat=account', '_blank'), title: 'Bật/tắt tự động login (double click để mở Cài đặt > Account)' }); li.appendChild(a); ul.insertBefore(li, dropdownLi); } // Helper: detect smartphone/small screens function shouldShowTopbarMenu() { try { return window.matchMedia && window.matchMedia('(max-width: 768px)').matches; } catch(_) { return false; } } // Thêm nút mở dashboard vào TOPBAR bên cạnh <li class="nav-item dropdown"> function addDashboardMenuToTopbar() { // Tìm topbar ul chứa các nav-item const dropdownLi = document.querySelector('nav.main-header ul.navbar-nav li.nav-item.dropdown') || document.querySelector('ul.navbar-nav li.nav-item.dropdown') || document.querySelector('li.nav-item.dropdown'); if (!dropdownLi) return; const ul = dropdownLi.parentElement; if (!ul) return; // On desktop, remove if present and skip if (!shouldShowTopbarMenu()) { const existing = ul.querySelector('.bsnt-dashboard-menu-top'); if (existing) existing.remove(); return; } // Tránh thêm trùng if (ul.querySelector('.bsnt-dashboard-menu-top')) return; const li = document.createElement('li'); li.className = 'nav-item bsnt-dashboard-menu-top'; const a = document.createElement('a'); a.className = 'nav-link'; a.href = '/?nln'; a.target = '_blank'; a.innerHTML = '<i class="fas fa-tachometer-alt"></i> <span style="margin-left:6px;">Mở dashboard</span>'; // Style vàng và bo tròn giống sidebar a.style.background = 'gold'; a.style.borderRadius = '12px'; a.style.color = '#333'; a.style.fontWeight = 'bold'; a.style.display = 'inline-flex'; a.style.alignItems = 'center'; a.style.gap = '6px'; a.style.padding = '6px 10px'; a.onmouseover = function () { a.style.background = '#ffe066'; }; a.onmouseout = function () { a.style.background = 'gold'; }; li.appendChild(a); if (dropdownLi.nextSibling) ul.insertBefore(li, dropdownLi.nextSibling); else ul.appendChild(li); } // Re-evaluate visibility on resize (bind once) if (!window.__bsntTopbarMenuResizeBound) { window.addEventListener('resize', () => { try { addDashboardMenuToTopbar(); } catch(_) {} }, { passive: true }); window.__bsntTopbarMenuResizeBound = true; } // --- HSBA V2 PAGE ENHANCEMENT: Hide empty sections (no documents) --- function HSBAV2HideEmptySectionsIfNeeded() { if (window.location.hostname === 'hsba.tahospital.vn') { function hideEmptySections() { document.querySelectorAll('div.MuiBox-root.css-0').forEach(div => { const p = div.querySelector('p'); if (p && /\(0\)\s*$/.test(p.textContent)) { div.style.display = 'none'; } }); } // Wait for all AJAX content to load before running hideEmptySections function waitForFullLoadAndHide() { let lastCount = 0; let stableCount = 0; const maxWait = 20000; // 20 seconds max const startTime = performance.now(); const interval = setInterval(() => { const currentCount = document.querySelectorAll('div.MuiBox-root.css-0').length; if (currentCount === lastCount) { stableCount++; } else { stableCount = 0; } lastCount = currentCount; // If count is stable for 10 checks (~10s) or maxWait reached, run hideEmptySections and click target div if (stableCount > 10 || (performance.now() - startTime) > maxWait) { clearInterval(interval); hideEmptySections(); // Click div with class 'MuiBox-root css-1ebnygn' const targetDiv = document.querySelector('div.MuiBox-root.css-1ebnygn'); if (targetDiv) { targetDiv.click(); } } // For debug: // console.log(`Current sections: ${currentCount}, Stable count: ${stableCount}`); }, 1000); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', waitForFullLoadAndHide); } else { waitForFullLoadAndHide(); } // Also observe for dynamic changes (in case of further AJAX updates) const observer = new MutationObserver(hideEmptySections); observer.observe(document.body, { childList: true, subtree: true }); } } HSBAV2HideEmptySectionsIfNeeded(); })(); },{"./DanhSachBenhNhan":2,"./components/autoLoginToggle":5,"./components/copyDienTienAI":6,"./components/hsbaDataFetcher":8,"./googleAppsScript":17,"./pages/otm-entry":18,"./pages/page.dashboard":20,"./pages/page.lichmo.homnay":22,"./pages/page.settings":24,"./services/checklistService":27,"./utils":34}],4:[function(require,module,exports){ // components/actionButtons.js - shared creators for action buttons const ChecklistService = require('../services/checklistService'); const ReportService = require('../services/reportService'); async function openHSBAV2Link(mabn) { try { const response = await fetch('/ToDieuTri/LoadLinkHsba', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'include', body: 'code=' + encodeURIComponent(mabn) }); const result = await response.json(); if (result && result.data && result.data.link) { window.open(result.data.link, '_blank'); } else { window.open(`/hoso/${encodeURIComponent(String(mabn))}`, '_blank'); } } catch (_) { window.open(`/hoso/${encodeURIComponent(String(mabn))}`, '_blank'); } } function createCopyOneButton({ item, variant = 'icon' }) { const isIcon = variant === 'icon'; const btn = document.createElement('button'); btn.className = isIcon ? 'dr-btn-icon' : 'dr-detail-btn no-print'; if (!isIcon) { btn.style.position = 'static'; btn.style.padding = '8px'; btn.style.borderRadius = '10px'; btn.style.display = 'inline-flex'; btn.style.alignItems = 'center'; btn.style.justifyContent = 'center'; } btn.title = 'Copy báo cáo (1 BN)'; btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path fill="#fff" d="M16 1H4a2 2 0 0 0-2 2v12h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z"/></svg>' + (isIcon ? '' : ' Copy'); btn.onclick = async (e) => { e.stopPropagation(); try { const { copyReportToClipboardRich } = require('../pages/page.dashboard.support'); const res = await ChecklistService.loadChecklistData(item); const obj = ChecklistService.findChecklistObject(res); const state = obj ? (ChecklistService.parseChecklistState(obj) || {}) : {}; const html = ReportService.generateSingleHTML(item, state); const text = ReportService.generateSingleText(item, state); await copyReportToClipboardRich(html, text); } catch (err) { console.error('Copy single-patient report failed:', err); } }; return btn; } function createToDieuTriButton({ item, variant = 'full' }) { const isIcon = variant === 'icon'; const btn = document.createElement('button'); btn.className = isIcon ? 'dr-btn-icon' : 'dr-detail-btn no-print'; if (!isIcon) { btn.style.position = 'static'; btn.style.fontSize = '14px'; btn.style.padding = '8px 12px 8px 10px'; } btn.title = 'Tờ điều trị'; const svgDoc = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path fill="#fff" d="M6 2h7l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm7 1v4h4l-4-4zM8 9h8v2H8V9zm0 4h8v2H8v-2zm0 4h5v2H8v-2z"/></svg>'; btn.innerHTML = isIcon ? svgDoc : `${svgDoc}Tờ điều trị`; btn.onclick = (e) => { e.stopPropagation(); if (item.mabn) window.open(`/to-dieu-tri?mabn=${encodeURIComponent(item.mabn)}`, '_blank'); }; return btn; } function createHsbaButton({ item, variant = 'full' }) { const isIcon = variant === 'icon'; const btn = document.createElement('button'); btn.className = isIcon ? 'dr-btn-icon' : 'dr-detail-btn no-print'; if (!isIcon) { btn.style.position = 'static'; btn.style.marginLeft = '8px'; btn.style.fontSize = '14px'; btn.style.padding = '8px 12px 8px 10px'; btn.textContent = 'HSBA V2'; } else { btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path fill="#fff" d="M12 5C5 5 2 12 2 12s3 7 10 7 10-7 10-7-3-7-10-7zm0 12c-4.97 0-8.19-4.16-8.94-5C3.81 10.16 7.03 6 12 6s8.19 4.16 8.94 5c-.75.84-3.97 5-8.94 5zm0-8a3 3 0 100 6 3 3 0 000-6z"/></svg>'; } btn.title = 'HSBA V2'; btn.onclick = (e) => { e.stopPropagation(); openHSBAV2Link(item.mabn); }; return btn; } module.exports = { createCopyOneButton, createToDieuTriButton, createHsbaButton, createHsbaV1Button, openHSBAV2Link }; // Legacy HSBA (v1) opener as a shared creator function createHsbaV1Button(item) { const btn = document.createElement('button'); btn.className = 'dr-detail-btn no-print'; btn.style.position = 'static'; btn.style.marginLeft = '8px'; btn.textContent = 'HSBAv1'; btn.onclick = function (e) { e.stopPropagation(); try { if (!item || !item.mabn) return; const url = `/hoso/${encodeURIComponent(String(item.mabn))}`; window.open(url, '_blank', 'noopener'); } catch (error) { console.warn('Open HSBAv1 failed', error); } }; return btn; } },{"../pages/page.dashboard.support":21,"../services/checklistService":27,"../services/reportService":29}],5:[function(require,module,exports){ // autoLoginToggle.js - Shared toggle UI for Auto Login function applyToggleStyles(a, enabled) { a.className = (a.className || '') + ' dr-autologin-toggle nav-link'; a.style.borderRadius = '12px'; a.style.display = 'inline-flex'; a.style.alignItems = 'center'; a.style.gap = '6px'; a.style.padding = '6px 10px'; if (enabled) { a.innerHTML = '<i class="fas fa-toggle-on"></i> <span style="margin-left:6px; font-weight:600;">TỰ ĐỘNG LOGIN</span>'; a.style.background = '#dc2626'; a.style.color = '#fff'; a.style.border = '1px solid #b91c1c'; } else { a.innerHTML = '<i class="fas fa-toggle-off"></i> <span style="margin-left:6px;">TỰ ĐỘNG LOGIN</span>'; a.style.background = '#fff'; a.style.color = '#111827'; a.style.border = '1px solid #e5e7eb'; } } function createAutoLoginToggle({ enabled, onToggle, onDblClick, title }) { const a = document.createElement('a'); a.href = 'javascript:void(0)'; a.title = title || 'Bật/tắt tự động login'; applyToggleStyles(a, !!enabled); a.addEventListener('click', (e) => { e.preventDefault(); if (typeof onToggle === 'function') onToggle(); }); if (typeof onDblClick === 'function') { a.addEventListener('dblclick', (e) => { e.preventDefault(); onDblClick(); }); } return a; } module.exports = { createAutoLoginToggle, applyToggleStyles }; },{}],6:[function(require,module,exports){ // copyDienTienAI.js // Inject a "Copy diễn tiến" button on /to-dieu-tri and copy all PDF text to clipboard using pdf.js function isToDieuTriPage() { try { return /\/to-dieu-tri(\?.*)?$/.test(window.location.pathname); } catch (_) { return false; } } function getMabnFromUrl() { try { const u = new URL(window.location.href); return u.searchParams.get('mabn') || ''; } catch (_) { return ''; } } function ensureStatusBar(container) { let bar = document.getElementById('dr-copy-dien-tien-status'); if (!bar) { bar = document.createElement('div'); bar.id = 'dr-copy-dien-tien-status'; bar.style.cssText = 'margin-top:8px; font-size:12px; color:#0f172a;'; container.appendChild(bar); } return bar; } // Helper to set status text with optional auto-clear after 4s function setStatus(bar, text, color, autoClear = false) { if (!bar) return; try { if (bar.__statusTimer) { clearTimeout(bar.__statusTimer); bar.__statusTimer = null; } } catch(_) {} if (typeof text === 'string') bar.textContent = text; if (color) bar.style.color = color; if (autoClear) { bar.__statusTimer = setTimeout(() => { try { bar.textContent = ''; } catch(_) {} }, 4000); } } async function loadPdfJsIfNeeded() { const getLib = () => (window.pdfjsLib || (typeof unsafeWindow !== 'undefined' ? unsafeWindow.pdfjsLib : undefined)); if (getLib()) { // worker may still need to be set try { const lib = getLib(); if (lib && lib.GlobalWorkerOptions) lib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.6.347/pdf.worker.min.js'; } catch(_) {} return; } const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.6.347/pdf.min.js'; script.referrerPolicy = 'no-referrer'; const p = new Promise((resolve, reject) => { script.onload = () => { try { // bridge between page and userscript contexts if (typeof unsafeWindow !== 'undefined' && unsafeWindow.pdfjsLib && !window.pdfjsLib) { try { window.pdfjsLib = unsafeWindow.pdfjsLib; } catch(_) {} } const lib = getLib(); if (lib && lib.GlobalWorkerOptions) lib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.6.347/pdf.worker.min.js'; } catch(_) {} resolve(); }; script.onerror = () => reject(new Error('Không tải được pdf.js')); }); document.head.appendChild(script); await p; } async function fetchPatientInfo(mabn) { const body = 'code=' + encodeURIComponent(mabn); const res = await fetch('/ToDieuTri/GetPatient', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', 'accept': '*/*' }, credentials: 'include', body }); if (!res.ok) throw new Error('Lỗi GetPatient: ' + res.status); const json = await res.json(); if (!json || json.isValid === false || !json.data || !json.data[0]) throw new Error('Dữ liệu GetPatient không hợp lệ'); return json.data[0]; } function parseMMDDYYYYtoDDMMYYYY(dateTimeStr) { if (!dateTimeStr) return ''; // Expect "MM/DD/YYYY HH:mm:ss" or "MM/DD/YYYY" const [datePart] = String(dateTimeStr).split(' '); const [mm, dd, yyyy] = datePart.split('/'); if (!mm || !dd || !yyyy) return ''; return `${dd.padStart(2, '0')}/${mm.padStart(2, '0')}/${yyyy}`; } function todayDDMMYYYY() { const d = new Date(); const dd = String(d.getDate()).padStart(2, '0'); const mm = String(d.getMonth() + 1).padStart(2, '0'); const yyyy = d.getFullYear(); return `${dd}/${mm}/${yyyy}`; } async function fetchPdfArrayBuffer(url) { const res = await fetch(url, { method: 'GET', credentials: 'include' }); if (!res.ok) throw new Error('Lỗi tải PDF: ' + res.status); return await res.arrayBuffer(); } async function extractAllTextFromPdfBuffer(buffer) { await loadPdfJsIfNeeded(); const pdfjsLib = (window.pdfjsLib || (typeof unsafeWindow !== 'undefined' ? unsafeWindow.pdfjsLib : undefined)); if (!pdfjsLib || !pdfjsLib.getDocument) throw new Error('pdfjsLib chưa sẵn sàng'); const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buffer) }); const pdf = await loadingTask.promise; let out = []; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const content = await page.getTextContent(); const strings = content.items.map(it => it.str).filter(Boolean); out.push(strings.join(' ')); } return out.join('\n\n'); } // Sanitize sensitive info before copying to clipboard function sanitizeCopiedText(text) { if (!text) return ''; let t = String(text); // Remove from "Họ và tên:" to the first '-' character (inclusive), not to newline t = t.replace(/Họ\s+và\s+tên:\s*[^-]*-\s*/gi, ''); return t; } async function copyToClipboard(text) { try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return true; } } catch(_) {} try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); return true; } catch(_) { return false; } } function injectButton() { if (!isToDieuTriPage()) return; const host = document.getElementById('LoadToDieuTri'); if (!host) return; if (document.getElementById('dr-copy-dien-tien-forAI')) return; // already added const wrap = document.createElement('div'); wrap.style.margin = '6px 0 10px 0'; const btn = document.createElement('button'); btn.id = 'dr-copy-dien-tien-forAI'; btn.type = 'button'; btn.className = 'btn btn-sm btn-success'; btn.textContent = 'Copy diễn tiến'; // Secondary "copy again" icon button const btnCopyAgain = document.createElement('button'); btnCopyAgain.type = 'button'; btnCopyAgain.title = 'Copy lại'; btnCopyAgain.className = 'btn btn-sm btn-outline-secondary'; btnCopyAgain.style.marginLeft = '6px'; btnCopyAgain.textContent = '📋'; btnCopyAgain.style.display = 'none'; wrap.appendChild(btn); wrap.appendChild(btnCopyAgain); host.prepend(wrap); const statusBar = ensureStatusBar(wrap); btn.addEventListener('click', async () => { const mabn = getMabnFromUrl(); if (!mabn) { setStatus(statusBar, 'Không tìm thấy MABN trong URL', '#b91c1c', true); return; } try { setStatus(statusBar, 'Đang lấy thông tin người bệnh...', '#0f172a', false); const info = await fetchPatientInfo(mabn); const mavaovien = info.maVaoVien || info.maVaoVien || info.mavaovien || ''; const ngayvv = parseMMDDYYYYtoDDMMYYYY(info.ngayVV || info.ngayvv || ''); const maql = info.maql || ''; if (!mavaovien || !ngayvv || !maql) { setStatus(statusBar, 'Thiếu tham số (mã vào viện/ngày vào/maql)', '#b91c1c', true); return; } const denngay = todayDDMMYYYY(); const pdfUrl = `/todieutri/DienBien/PrintPDF?id=&mabn=${encodeURIComponent(mabn)}&mavaovien=${encodeURIComponent(mavaovien)}&tungay=${encodeURIComponent(ngayvv)}&denngay=${encodeURIComponent(denngay)}&maql=${encodeURIComponent(maql)}`; setStatus(statusBar, 'Đang tải và xử lý PDF...', '#0f172a', false); const buf = await fetchPdfArrayBuffer(pdfUrl); const rawText = await extractAllTextFromPdfBuffer(buf); const text = sanitizeCopiedText(rawText); setStatus(statusBar, 'Đang copy vào clipboard...', '#0f172a', false); const ok = await copyToClipboard(text); if (ok) { setStatus(statusBar, 'Đã copy toàn bộ diễn tiến vào clipboard.', '#166534', true); // Enable copy-again with latest sanitized text btnCopyAgain.dataset.clipboardText = text; btnCopyAgain.style.display = 'inline-block'; } else { setStatus(statusBar, 'Không thể copy vào clipboard.', '#b91c1c', true); } } catch (err) { console.error(err); setStatus(statusBar, 'Lỗi: ' + (err && err.message ? err.message : 'Không rõ'), '#b91c1c', true); } }); // Copy-again action: copy last cached text without reloading PDF btnCopyAgain.addEventListener('click', async () => { const cached = btnCopyAgain.dataset.clipboardText || ''; if (!cached) { setStatus(statusBar, 'Chưa có dữ liệu để copy lại.', '#b91c1c', true); return; } try { setStatus(statusBar, 'Đang copy vào clipboard...', '#0f172a', false); const ok = await copyToClipboard(cached); if (ok) setStatus(statusBar, 'Đã copy lại vào clipboard.', '#166534', true); else setStatus(statusBar, 'Không thể copy vào clipboard.', '#b91c1c', true); } catch (e) { setStatus(statusBar, 'Lỗi: ' + (e && e.message ? e.message : 'Không rõ'), '#b91c1c', true); } }); } function initCopyDienTienAI() { if (!isToDieuTriPage()) return; // Try immediately and a few retries in case DOM is populated later const tryInject = () => { injectButton(); }; tryInject(); let tries = 0; const iv = setInterval(() => { tries++; injectButton(); if (document.getElementById('dr-copy-dien-tien-forAI') || tries > 20) clearInterval(iv); }, 300); } module.exports = { initCopyDienTienAI, fetchPatientInfo, parseMMDDYYYYtoDDMMYYYY, todayDDMMYYYY, fetchPdfArrayBuffer, extractAllTextFromPdfBuffer, copyToClipboard, sanitizeCopiedText, setStatus }; },{}],7:[function(require,module,exports){ // dialogManager.js - Manager for dialogs and modals const DialogManager = { /** * Create a modal dialog */ createDialog(id, options = {}) { // Remove existing dialog if exists const existing = document.getElementById(id); if (existing) existing.remove(); const dialog = document.createElement('div'); dialog.id = id; dialog.style = ` position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 1000001; background: rgba(0,0,0,0.25); display: flex; align-items: center; justify-content: center; `; const inner = document.createElement('div'); inner.style = ` background: #fff; padding: 32px 24px 24px 24px; max-width: ${options.maxWidth || '1000px'}; width: 95vw; max-height: ${options.maxHeight || '88vh'}; overflow-y: auto; border-radius: 12px; box-shadow: 0 4px 32px rgba(0,0,0,0.18); position: relative; `; dialog.appendChild(inner); document.body.appendChild(dialog); // Click outside to close if (options.closeOnClickOutside !== false) { dialog.onclick = function (e) { if (e.target === dialog) dialog.remove(); }; } return { dialog, inner }; }, /** * Create action buttons for dialog */ createActionButtons(buttons) { const buttonContainer = document.createElement('div'); buttonContainer.style = 'margin-top:18px;display:flex;gap:12px;justify-content:flex-end;flex-wrap:wrap;'; buttons.forEach(button => { const btn = document.createElement('button'); btn.id = button.id; btn.className = button.className || 'btn'; btn.textContent = button.text; btn.onclick = button.onclick; // Add visual styles for easier recognition btn.style.padding = '8px 14px'; btn.style.borderRadius = '8px'; btn.style.border = '1px solid #cbd5e1'; btn.style.cursor = 'pointer'; btn.style.fontWeight = '600'; if (btn.className.includes('btn-primary')) { btn.style.background = 'linear-gradient(180deg, #1e88e5, #1976d2)'; btn.style.color = '#fff'; btn.style.borderColor = '#1976d2'; } else if (btn.className.includes('btn-secondary')) { btn.style.background = '#f8fafc'; btn.style.color = '#0f172a'; btn.style.borderColor = '#cbd5e1'; } buttonContainer.appendChild(btn); }); return buttonContainer; }, /** * Show toast notification */ showToast(message, options = {}) { const toast = document.createElement('div'); toast.innerText = message; toast.style = ` position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); background: ${options.background || '#1976d2'}; color: ${options.color || '#fff'}; padding: 12px 28px; border-radius: 8px; font-size: 1.1em; z-index: 1000002; box-shadow: 0 2px 12px rgba(25,118,210,0.15); transition: opacity 0.3s; `; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 400); }, options.duration || 1800); } }; module.exports = DialogManager; },{}],8:[function(require,module,exports){ // hsbaDataFetcher.js - Fetch HSBA V2 data via background tab and GraphQL /* Contract - addHSBATab(rootEl, patient): - Adds a new tab button "HSBA Data" into `.checklist-tabs` within the provided rootEl (sidebar right column content) - Renders a tab pane with a Fetch button (id: dr-hsb-fetch-btn) to trigger the flow - Uses GM_openInTab to open the HSBA V2 link silently, waits for background tab to collect data on hsba.tahospital.vn, then displays results - Background (auto-run when host === hsba.tahospital.vn): - Wait until the main grid appears (div.MuiGrid-root) - Parse pid from URL (public?pid=<...>) - Run fetch to /graphql using current domain’s cookies (no cross-origin hack) - Store results via GM_setValue under key `dr_hsba_result_${pid}` Notes - Requires Tampermonkey grants: GM_openInTab, GM_setValue, GM_addValueChangeListener (already used in this repo for GM_xmlhttpRequest) - Falls back gracefully when grants aren’t available (opens in foreground and asks user to wait) */ const DialogManager = require('./dialogManager'); const ChecklistService = require('../services/checklistService'); const BS_CAI_DAT = (() => { try { return require('../BS_CAI_DAT_GIAO_DIEN'); } catch(_) { return (typeof window !== 'undefined' && window.BS_CAI_DAT) ? window.BS_CAI_DAT : {}; } })(); // Track opened HSBA tabs by patient id to auto-close after data arrives const HSBA_OPEN_TABS = new Map(); function registerOpenedTab(pid, ref) { try { const prev = HSBA_OPEN_TABS.get(pid); if (prev && typeof prev.close === 'function') { try { prev.close(); } catch(_) {} } } catch(_) {} HSBA_OPEN_TABS.set(pid, ref); try { console.log('[DR][HSBA] registered background tab for pid:', pid, ref); } catch(_) {} } function closeOpenedTab(pid, reason = 'done') { try { const ref = HSBA_OPEN_TABS.get(pid); if (ref && typeof ref.close === 'function') { try { ref.close(); console.log('[DR][HSBA] closed background tab (GM_openInTab) for pid:', pid, 'reason:', reason); } catch(e) { console.warn('[DR][HSBA] close tab error:', e); } } else if (ref && typeof ref === 'object' && 'close' in ref) { try { ref.close(); console.log('[DR][HSBA] closed background window for pid:', pid, 'reason:', reason); } catch(e) { console.warn('[DR][HSBA] close window error:', e); } } else { console.warn('[DR][HSBA] no tabRef to close for pid:', pid, 'reason:', reason); } } catch(e) { console.warn('[DR][HSBA] closeOpenedTab exception:', e); } HSBA_OPEN_TABS.delete(pid); } // Build rules from BS_CAI_DAT.HSBA_CHECKLIST_MAP (array of rule objects) const __HSBA_RULES__ = Array.isArray(BS_CAI_DAT.HSBA_CHECKLIST_MAP) ? BS_CAI_DAT.HSBA_CHECKLIST_MAP : []; const HSBA_SHOW_SET = new Set(__HSBA_RULES__.filter(r => r && r.tenmau && r.show).map(r => r.tenmau)); const HSBA_SYNC_SET = new Set(__HSBA_RULES__.filter(r => r && r.tenmau && r.sync).map(r => r.tenmau)); const HSBA_TENMAU_TO_CHECKLIST = __HSBA_RULES__.reduce((acc, r) => { if (r && r.sync && r.tenmau && r.checklist) acc[r.tenmau] = r.checklist; return acc; }, {}); function createEl(tag, attrs = {}, children = []) { const el = document.createElement(tag); Object.entries(attrs).forEach(([k, v]) => { if (k === 'style' && typeof v === 'object') { Object.assign(el.style, v); } else if (k === 'dataset' && v && typeof v === 'object') { Object.entries(v).forEach(([dk, dv]) => el.dataset[dk] = dv); } else if (k in el) { try { el[k] = v; } catch(_) { el.setAttribute(k, v); } } else { el.setAttribute(k, v); } }); (Array.isArray(children) ? children : [children]).forEach(c => { if (c == null) return; if (typeof c === 'string') el.appendChild(document.createTextNode(c)); else el.appendChild(c); }); return el; } function formatDateYYYYMMDD(d = new Date()) { const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; } function parseDateSafe(s) { if (!s || typeof s !== 'string') return null; // Try ISO first; fallback to replace spaces let dt = new Date(s); if (isNaN(dt.getTime())) { try { dt = new Date(s.replace(' ', 'T')); } catch(_) {} } return isNaN(dt.getTime()) ? null : dt; } function formatDateDDMMYYYY(dt) { if (!(dt instanceof Date) || isNaN(dt.getTime())) return ''; const dd = String(dt.getDate()).padStart(2, '0'); const mm = String(dt.getMonth() + 1).padStart(2, '0'); const yyyy = dt.getFullYear(); return `${dd}/${mm}/${yyyy}`; } function formatDateTimeDDMMYYYYHHmm(dt) { if (!(dt instanceof Date) || isNaN(dt.getTime())) return ''; const ddmmyyyy = formatDateDDMMYYYY(dt); const hh = String(dt.getHours()).padStart(2, '0'); const mi = String(dt.getMinutes()).padStart(2, '0'); return `${ddmmyyyy} ${hh}:${mi}`; } async function getHSBAV2Link(mabn) { try { const res = await fetch('/ToDieuTri/LoadLinkHsba', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'include', body: 'code=' + encodeURIComponent(mabn) }); const json = await res.json(); if (json && json.data && json.data.link) return json.data.link; return `/hoso/${encodeURIComponent(String(mabn))}`; // fallback v1 } catch (e) { return `/hoso/${encodeURIComponent(String(mabn))}`; // fallback v1 } } function renderResult(container, result, ctx = {}) { // Render only filtered data (documents with allowed "tenmau") container.innerHTML = ''; try { console.log('[DR][HSBA] filtered result received:', result); } catch(_) {} if (!result || !result.data || !result.data.hoSoBenhAns) { container.textContent = 'Không có dữ liệu HSBA.'; return; } const hs = result.data.hoSoBenhAns; const rawItems = Array.isArray(hs.items) ? hs.items : []; // Filter: skip episodes with hoten === null (do not display) const items = rawItems.filter(it => it && it.hoten != null); // Count only valid, displayable docs (have tenfile or fileName) const docCount = items.reduce((sum, it) => sum + (Array.isArray(it.hoSoChiTiet) ? it.hoSoChiTiet.reduce((s, g) => s + (Array.isArray(g.chiTiets) ? g.chiTiets.filter(x => (x && (x.tenfile || x.fileName) && x.tenmau)).length : 0), 0) : 0), 0); const summary = createEl('div', { style: { marginBottom: '8px' } }, [ createEl('div', {}, `Tổng số đợt HSBA: ${hs.total != null ? hs.total : items.length}`), createEl('div', {}, `Số tài liệu đã lọc: ${docCount}`) ]); container.appendChild(summary); if (items.length === 0) return; // Determine current episode: prefer one with ngayra null, otherwise the latest by ngayvao const pickEpisode = () => { const open = items.filter(it => !it.ngayra); const arr = (open.length ? open : items).slice(); arr.sort((a,b) => { const ta = parseDateSafe(a.ngayvao)?.getTime() || 0; const tb = parseDateSafe(b.ngayvao)?.getTime() || 0; return tb - ta; // newest first }); return arr[0] || null; }; const currentEpisode = pickEpisode(); // From current episode, compute HSBA doc matches and persist to checklist state try { if (currentEpisode && Array.isArray(currentEpisode.hoSoChiTiet)) { const epStart = parseDateSafe(currentEpisode.ngayvao); const epEnd = parseDateSafe(currentEpisode.ngayra); const startTs = epStart ? epStart.getTime() : -Infinity; const endTs = epEnd ? epEnd.getTime() : Infinity; const docSet = new Set(); let latestDocDates = {}; currentEpisode.hoSoChiTiet.forEach(g => { (Array.isArray(g.chiTiets) ? g.chiTiets : []).forEach(d => { if (!d || !d.tenmau) return; if (!HSBA_SYNC_SET.has(d.tenmau)) return; // Only consider documents within the current episode date range const dDate = parseDateSafe(d.ngay); if (!dDate) return; const ts = dDate.getTime(); if (ts < startTs || ts > endTs) return; docSet.add(d.tenmau); const prev = latestDocDates[d.tenmau] || 0; if (ts > prev) latestDocDates[d.tenmau] = ts; }); }); const map = HSBA_TENMAU_TO_CHECKLIST; const nowIso = new Date().toISOString(); const hsbaSynced = Object.create(null); for (const tenmau of docSet) { const target = map[tenmau]; if (!target) continue; const dateTs = latestDocDates[tenmau] || 0; hsbaSynced[target] = { matched: true, source: 'hsba', docName: tenmau, docDate: dateTs ? new Date(dateTs).toISOString() : null, updatedAt: nowIso }; } if (Object.keys(hsbaSynced).length) { // Merge into window.checklistState and persist if (!window.checklistState) window.checklistState = {}; const prev = window.checklistState.hsbaSynced || {}; window.checklistState.hsbaSynced = { ...prev, ...hsbaSynced, __lastSyncAt: nowIso }; if (window.checklistObj && ChecklistService && typeof ChecklistService.updateChecklistState === 'function') { ChecklistService.updateChecklistState(window.checklistObj, window.checklistState, { enqueueOnOffline: true, ctxId: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.id), signal: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.signal) }) .then(() => { // Ask dashboard to refresh checklist badges if function exists try { if (typeof window.dr_refreshChecklistBadges === 'function') window.dr_refreshChecklistBadges(); } catch(_) {} }) .catch(() => {}); } else { try { if (typeof window.dr_refreshChecklistBadges === 'function') window.dr_refreshChecklistBadges(); } catch(_) {} } } } } catch(_) {} const outer = createEl('div', { className: 'dr-hsba-container', style: { maxHeight: '320px', overflowY: 'auto', border: '1px solid #e5e7eb', borderRadius: '8px', padding: '8px' } }); items.forEach((it, idx) => { const headerParts = []; if (it.hoten) headerParts.push(it.hoten); if (it.mabn) headerParts.push(it.mabn); if (it.tenkp) headerParts.push(it.tenkp); const ngayVaoDt = parseDateSafe(it.ngayvao); const ngayVaoStr = formatDateDDMMYYYY(ngayVaoDt); const header = createEl('div', { className: 'dr-hsba-episode-title', style: { fontWeight: '700', margin: '8px 0 6px', color: '#fff', background: '#1976d2' } }, headerParts.concat(ngayVaoStr ? [ngayVaoStr] : []).join(' - ')); outer.appendChild(header); const groups = Array.isArray(it.hoSoChiTiet) ? it.hoSoChiTiet : []; groups.forEach(g => { const rawDocs = Array.isArray(g.chiTiets) ? g.chiTiets : []; if (!rawDocs.length) return; const gTitle = createEl('div', { className: 'dr-hsba-group-title', style: { fontWeight: '600', margin: '4px 0', color: '#334155' } }, `${g.tengay || g.gayid || 'Mục'}:`); outer.appendChild(gTitle); const ul = createEl('ul', { className: 'dr-hsba-list', style: { margin: 0, paddingLeft: '18px', listStyle: 'disc' } }); // Build displayable docs: must have tenmau and tenfile/fileName const docs = rawDocs .map(d => ({ ...d, _tenfile: d && (d.tenfile || d.fileName) || '', _date: parseDateSafe(d && d.ngay) })) .filter(d => { if (!d) return false; if (!d.tenmau || !HSBA_SHOW_SET.has(d.tenmau)) { try { console.debug('[DR][HSBA] skip doc (tenmau not allowed):', d); } catch(_) {} return false; } if (!d._tenfile) { try { console.warn('[DR][HSBA] skip doc (missing tenfile):', d); } catch(_) {} return false; } return true; }) .sort((a, b) => { const ta = a._date ? a._date.getTime() : -Infinity; const tb = b._date ? b._date.getTime() : -Infinity; return tb - ta; // descending (newest first) }); try { console.log('[DR][HSBA] group sorted docs:', { group: g.tengay || g.gayid, count: docs.length }); } catch(_) {} const openViewer = async (tenfile, tenmau, ngayDisplay) => { try { const patient = ctx && ctx.patient; const mabn = patient && (patient.pid || patient.mabn); if (!mabn) { console.error('[DR][HSBA] openViewer: missing mabn'); return; } // Get a fresh HSBA V2 link with tokens (pid/s/t/site) and append hash with file to view inline const baseLink = await getHSBAV2Link(mabn); const parts = []; parts.push(`dr-viewer=${encodeURIComponent(tenfile)}`); if (tenmau) parts.push(`dr-name=${encodeURIComponent(tenmau)}`); if (ngayDisplay) parts.push(`dr-date=${encodeURIComponent(ngayDisplay)}`); const hash = parts.join('&'); const url = `${baseLink}${baseLink.includes('#') ? '' : '#'}${baseLink.includes('#') ? '&' : ''}${hash}`; console.log('[DR][HSBA] opening inline viewer:', { mabn, tenfile, tenmau, ngayDisplay, url }); window.open(url, '_blank'); } catch (err) { console.error('[DR][HSBA] openViewer error:', err); } }; docs.forEach(d => { const nd = d._date || parseDateSafe(d && d.ngay); const ngayFmt = formatDateTimeDDMMYYYYHHmm(nd) || (d && d.ngay) || ''; const label = `${d.tenmau} - ${ngayFmt}`; const li = createEl('li', { className: 'dr-hsba-item', title: 'Mở tài liệu ở tab mới', dataset: { tenfile: d._tenfile }, style: { cursor: 'pointer', padding: '2px 0' } }, label); li.addEventListener('click', () => { const tf = li.dataset.tenfile || ''; if (!tf) { console.error('[DR][HSBA] click but missing data-tenfile'); return; } openViewer(tf, d.tenmau, ngayFmt); }); ul.appendChild(li); }); outer.appendChild(ul); }); if (idx < items.length - 1) outer.appendChild(createEl('hr', { style: { border: 'none', borderTop: '1px dashed #e5e7eb', margin: '8px 0' } })); }); container.appendChild(outer); } function attachTabToggleBehavior(rootEl) { const tabs = rootEl.querySelectorAll('.checklist-tabs .tab-btn'); const panes = rootEl.querySelectorAll('.tab-content .tab-pane'); tabs.forEach(btn => { if (btn.__drBound) return; btn.__drBound = true; btn.addEventListener('click', function() { const targetTab = this.getAttribute('data-tab'); tabs.forEach(b => { b.classList.remove('active'); b.style.background = 'transparent'; b.style.color = '#666'; b.style.fontWeight = 'normal'; }); this.classList.add('active'); this.style.background = '#0ea5e9'; this.style.color = '#fff'; this.style.fontWeight = 'bold'; panes.forEach(p => { p.classList.remove('active'); p.style.display = 'none'; }); const pane = rootEl.querySelector(`.tab-pane[data-tab="${targetTab}"]`); if (pane) { pane.classList.add('active'); pane.style.display = 'block'; } }); }); } function addHSBATab(rootEl, patient) { try { if (!rootEl) return; const tabsBar = rootEl.querySelector('.checklist-tabs'); const tabContent = rootEl.querySelector('.tab-content'); if (!tabsBar || !tabContent) return; // Avoid duplicate if (tabsBar.querySelector('#dr-hsba-tab-btn')) return; const btn = createEl('button', { id: 'dr-hsba-tab-btn', className: 'tab-btn', dataset: { tab: 'hsba' }, style: { padding: '8px 16px', border: 'none', background: 'transparent', color: '#666', borderRadius: '4px 4px 0 0', cursor: 'pointer', marginLeft: '4px' } }, 'HSBA Data'); tabsBar.appendChild(btn); const pane = createEl('div', { className: 'tab-pane', dataset: { tab: 'hsba' }, style: { display: 'none' } }); const status = createEl('div', { id: 'dr-hsba-status', style: { margin: '6px 0', color: '#0f172a' } }); const resultBox = createEl('div', { id: 'dr-hsba-result', style: { fontSize: '13px' } }); const btnFetch = createEl('button', { id: 'dr-hsb-fetch-btn', className: 'btn btn-primary', style: { padding: '8px 14px', borderRadius: '8px', cursor: 'pointer' } }, 'Lấy HSBA từ file'); // When clicking the HSBA tab, auto-fetch if there is no data yet btn.addEventListener('click', () => { try { const empty = !resultBox || (!resultBox.firstChild && !String(resultBox.textContent || '').trim()); if (empty) { btnFetch.click(); } } catch (_) {} }); btnFetch.addEventListener('click', async () => { // Defensive: ensure patient exists const mabn = patient && (patient.pid || patient.mabn); if (!mabn) { status.textContent = 'Không tìm thấy MABN.'; return; } status.textContent = 'Đang mở HSBA V2 trong nền...'; const link = await getHSBAV2Link(mabn); let tabRef = null; try { if (typeof GM_openInTab === 'function') { tabRef = GM_openInTab(link, { active: false, insert: true }); registerOpenedTab(String(mabn), tabRef); } else { const w = window.open(link, '_blank'); if (w) registerOpenedTab(String(mabn), w); } } catch(_) { const w = window.open(link, '_blank'); if (w) registerOpenedTab(String(mabn), w); } // Result key for cross-tab delivery const key = `dr_hsba_result_${mabn}`; // 1) Realtime listener when supported if (typeof GM_addValueChangeListener === 'function') { GM_addValueChangeListener(key, function(name, oldVal, newVal, remote) { if (!remote || !newVal) return; try { const payload = typeof newVal === 'string' ? JSON.parse(newVal) : newVal; try { console.log('[DR][HSBA] payload received via listener:', payload); } catch(_) {} renderResult(resultBox, payload, { patient }); status.textContent = 'Đã lấy HSBA.'; // Close the background tab for this patient setTimeout(() => closeOpenedTab(String(mabn), 'listener'), 300); } catch (e) { status.textContent = 'Lỗi phân tích dữ liệu HSBA.'; console.warn(e); } }); } // 2) Polling fallback when listener is unavailable or unreliable let attempts = 0; const maxAttempts = 60; // ~60s if (typeof GM_getValue === 'function') { const iv = setInterval(async () => { try { attempts++; const raw = await GM_getValue(key, null); if (raw) { clearInterval(iv); const payload = typeof raw === 'string' ? JSON.parse(raw) : raw; try { console.log('[DR][HSBA] payload received via polling:', payload); } catch(_) {} renderResult(resultBox, payload, { patient }); status.textContent = 'Đã lấy HSBA.'; // Close the background tab for this patient setTimeout(() => closeOpenedTab(String(mabn), 'polling'), 300); return; } if (attempts === 5 && !resultBox.firstChild) { status.textContent = 'Đang chờ HSBA tải xong... (có thể 5–15s)'; } if (attempts >= maxAttempts) { clearInterval(iv); if (!resultBox.firstChild) status.textContent = 'Hết thời gian chờ HSBA.'; } } catch (_) {} }, 1000); } else if (!resultBox.firstChild) { status.textContent = 'Không hỗ trợ lắng nghe nền. Hãy chuyển sang tab HSBA để tải xong, rồi quay lại.'; } }); pane.appendChild(btnFetch); pane.appendChild(status); pane.appendChild(resultBox); tabContent.appendChild(pane); // Bind toggle behavior (for newly added button) attachTabToggleBehavior(rootEl); } catch (e) { console.warn('addHSBATab error', e); } } // Background worker on hsba.tahospital.vn — auto-fetch GraphQL and publish via GM_setValue async function hsbaBackgroundFetcherIfNeeded() { try { if (typeof window === 'undefined') return; if (window.location.hostname !== 'hsba.tahospital.vn') return; const params = new URLSearchParams(window.location.search); const pid = params.get('pid'); const s = params.get('s') || ''; const t = params.get('t') || ''; const site = params.get('site') || '1'; if (!pid) return; function waitForGrid() { return new Promise(resolve => { // Prefer the explicit container; fallback to generic grid if classnames change const targetSelectors = [ 'div.MuiGrid-root' ]; if (targetSelectors.some(q => document.querySelector(q))) return resolve(true); const obs = new MutationObserver(() => { if (targetSelectors.some(q => document.querySelector(q))) { obs.disconnect(); resolve(true); } }); obs.observe(document.documentElement || document.body, { childList: true, subtree: true }); // Fallback timeout setTimeout(() => { try { obs.disconnect(); } catch(_) {} resolve(true); }, 15000); }); } await waitForGrid(); const today = formatDateYYYYMMDD(new Date()); const body = { operationName: 'hoSoBenhAns', variables: { mabn: String(pid), tuNgay: '2024-01-01', denNgay: today, offset: 0, limit: 1000 }, query: `query hoSoBenhAns($mabn: String, $tuNgay: DateTime, $denNgay: DateTime, $daKy: Boolean, $offset: Int, $limit: Int, $makp: String, $raVien: Boolean) { hoSoBenhAns( mabn: $mabn tuNgay: $tuNgay denNgay: $denNgay daKy: $daKy offset: $offset limit: $limit makp: $makp raVien: $raVien ) { items { mabn hoten ngaysinh phai diachi mavaovien sovaovien doituong tenkp ngayvao ngayra chandoan tenba ngayky loai dienthoai tenfile fileName tuoi daky maql tennguoiky coTheKyTong ChiDinhNgoai nguoiky hoSoChiTiet { stt gayid tengay chiTiets { id tenfile fileName ngay tenmau daky coTheKyChiTiet congkhai maql BieuMau { id maphieu stt gayid slkyso ghichu loaiphieu trangthai chophepxoa congkhai xemtomtat NhomBieuMau { id ten __typename } __typename } __typename } __typename } loaidieutri __typename } total offset limit __typename } }` }; // Inject a main-world script that performs the fetch with the exact headers and posts the result back try { if (!window.__dr_hsba_injected__) { window.__dr_hsba_injected__ = true; // Listen for result from main world and persist via GM_setValue window.addEventListener('message', (ev) => { try { const d = ev && ev.data; if (!d || d.type !== 'DR_HSBA_RESULT' || d.pid !== pid) return; // Filter payload to only keep allowed tenmau docs let filtered = d.payload || {}; try { const src = d.payload; if (src && src.data && src.data.hoSoBenhAns) { const cloned = JSON.parse(JSON.stringify(src)); const items = Array.isArray(cloned.data.hoSoBenhAns.items) ? cloned.data.hoSoBenhAns.items : []; items.forEach(it => { if (Array.isArray(it.hoSoChiTiet)) { it.hoSoChiTiet.forEach(g => { if (Array.isArray(g.chiTiets)) { g.chiTiets = g.chiTiets.filter(x => !x || !x.tenmau ? false : HSBA_SHOW_SET.has(x.tenmau)); } }); } }); filtered = cloned; } } catch(_) {} if (typeof GM_setValue === 'function') { GM_setValue(`dr_hsba_result_${pid}`, JSON.stringify(filtered)); } else { window.__dr_hsba_result__ = filtered; } } catch(_) {} }); const refUrl = `${window.location.origin}/public?pid=${encodeURIComponent(pid)}&t=${encodeURIComponent(t)}&s=${encodeURIComponent(s)}&site=${encodeURIComponent(site)}`; const script = document.createElement('script'); script.type = 'text/javascript'; script.textContent = `(() => { try { const pid = ${JSON.stringify(pid)}; const s = ${JSON.stringify(s)}; const t = ${JSON.stringify(t)}; const site = ${JSON.stringify(String(site))}; const body = ${JSON.stringify(body)}; const referrer = ${JSON.stringify(refUrl)}; const headers = { "accept": "*/*", "accept-language": "en-US,en;q=0.9,vi;q=0.8", "content-type": "application/json", pid: String(pid), priority: "u=1, i", s: s, site: String(site), t: t }; // Inline viewer: if URL hash has dr-viewer, fetch the file and render via blob URL const showInlineViewer = (filePath, docName, docDate) => { try { const overlay = document.createElement('div'); overlay.id = 'dr-hsba-viewer-overlay'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,23,42,0.75);z-index:2147483647;display:flex;align-items:center;justify-content:center;padding:0;width:100vw;height:100vh;'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); const frameWrap = document.createElement('div'); frameWrap.id = 'dr-hsba-viewer-framewrap'; frameWrap.style.cssText = 'background:#fff;width:100vw;height:100vh;box-shadow:0 10px 30px rgba(0,0,0,0.4);border-radius:8px;display:flex;flex-direction:column;overflow:hidden;margin:0;'; const bar = document.createElement('div'); bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#0ea5e9;color:#fff;font-weight:700;'; const title = document.createElement('span'); title.textContent = 'Xem tài liệu HSBA'; const actions = document.createElement('div'); actions.style.cssText = 'display:flex;gap:8px;align-items:center;'; const btnDownload = document.createElement('button'); btnDownload.id = 'dr-hsba-viewer-download'; btnDownload.textContent = 'Tải xuống'; btnDownload.style.cssText = 'background:#fff;color:#0f172a;border:none;border-radius:6px;padding:4px 8px;cursor:pointer;'; const prevOverflow = document.body && document.body.style ? document.body.style.overflow : ''; let currentBlobUrl = null; const revokeUrl = () => { try { if (currentBlobUrl) { URL.revokeObjectURL(currentBlobUrl); currentBlobUrl = null; } } catch(_) {} }; const doClose = () => { try { revokeUrl(); if (document.body) document.body.style.overflow = prevOverflow || ''; overlay.remove(); } catch(_) {} }; actions.appendChild(btnDownload); bar.appendChild(title); bar.appendChild(actions); const iframe = document.createElement('iframe'); iframe.id = 'dr-hsba-viewer-iframe'; iframe.style.cssText = 'flex:1;border:0;background:#1f2937'; frameWrap.appendChild(bar); frameWrap.appendChild(iframe); overlay.appendChild(frameWrap); if (document && document.body) { document.body.style.overflow = 'hidden'; } (document.body || document.documentElement).appendChild(overlay); // ESC disabled per requirements const getFileName = () => { try { const sanitize = (s) => (s || '').replace(/[\\/:*?"<>|]/g, ' ').replace(/\s+/g, ' ').trim(); const namePart = sanitize(docName || 'HSBA'); const datePart = sanitize(docDate || ''); const combined = (namePart + (datePart ? ' - ' + datePart : '')).trim() || 'hsba-document'; return combined + '.pdf'; } catch(_) { return 'hsba-document.pdf'; } }; const fileName = getFileName(); const triggerDownload = () => { try { if (currentBlobUrl) { const a = document.createElement('a'); a.href = currentBlobUrl; a.download = fileName; document.body.appendChild(a); a.click(); a.remove(); } else { // Fallback: navigate to API to download with credentials const a = document.createElement('a'); a.href = '/api/hosobenhan/download?url=' + encodeURIComponent(filePath); a.target = '_blank'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); } } catch(_) {} }; btnDownload.onclick = () => triggerDownload(); fetch('/api/hosobenhan/download?url=' + encodeURIComponent(filePath), { credentials: 'include' }) .then(r => r.blob()) .then(blob => { const u = URL.createObjectURL(blob); currentBlobUrl = u; iframe.src = u; }) .catch(err => { console.error('[DR][HSBA] viewer fetch error:', err); doClose(); }); } catch (e) { console.error('[DR][HSBA] viewer error:', e); } }; try { const h = window.location.hash || ''; const m = h.match(/[#&]dr-viewer=([^&]+)/); if (m && m[1]) { const filePath = decodeURIComponent(m[1]); console.log('[DR][HSBA] inline viewer param detected:', filePath); let name = null, dateLabel = null; const n = h.match(/[#&]dr-name=([^&]+)/); if (n && n[1]) name = decodeURIComponent(n[1]); const d2 = h.match(/[#&]dr-date=([^&]+)/); if (d2 && d2[1]) dateLabel = decodeURIComponent(d2[1]); showInlineViewer(filePath, name, dateLabel); } } catch(_) {} fetch("/graphql", { headers, referrer: referrer, body: JSON.stringify(body), method: "POST", mode: "cors", credentials: "include" }).then(r => r.json()).then(json => { try { console.log('[DR][HSBA] raw API response:', json); } catch(_) {} window.postMessage({ type: 'DR_HSBA_RESULT', pid, payload: json }, '*'); }).catch(err => { window.postMessage({ type: 'DR_HSBA_RESULT', pid, payload: { error: String(err && err.message || err) } }, '*'); }); } catch (e) { try { window.postMessage({ type: 'DR_HSBA_RESULT', pid: ${JSON.stringify(pid)}, payload: { error: String(e && e.message || e) } }, '*'); } catch(_) {} } })();`; (document.head || document.documentElement || document.body).appendChild(script); // Optional: remove the script node after injected setTimeout(() => { try { script.remove(); } catch(_) {} }, 1000); } } catch(_) {} } catch (e) { // Swallow errors to avoid impacting page console.warn('hsbaBackgroundFetcherIfNeeded error', e); } } // Run background fetcher immediately on hsba domain try { hsbaBackgroundFetcherIfNeeded(); } catch(_) {} module.exports = { addHSBATab }; },{"../BS_CAI_DAT_GIAO_DIEN":1,"../services/checklistService":27,"./dialogManager":7}],9:[function(require,module,exports){ // components/khoaSelect.js - Reusable khoa selection button with dropdown const ApiService = require('../services/apiService'); function ensureStyles() { if (document.getElementById('dr-khoa-select-css')) return; const st = document.createElement('style'); st.id = 'dr-khoa-select-css'; st.textContent = ` .dr-khoa-select{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid #e5e7eb;border-radius:8px;background:#fff;color:#0f172a;font-size:13px;cursor:pointer} .dr-khoa-caret{border:solid #64748b;border-width:0 2px 2px 0;display:inline-block;padding:2px;transform:rotate(45deg);margin-left:2px} .dr-khoa-select-wrap{position:relative;display:inline-block} .dr-khoa-menu{position:absolute;top:110%;left:0;min-width:220px;max-height:320px;overflow:auto;background:#fff;border:1px solid #e5e7eb;border-radius:8px;box-shadow:0 10px 20px rgba(2,6,23,.08);z-index:9999;padding:6px} .dr-khoa-item{padding:6px 8px;border-radius:6px;cursor:pointer} .dr-khoa-item:hover{background:#f1f5f9} .dr-khoa-active{background:#e0f2fe} .dr-khoa-search{display:block;width:100%;box-sizing:border-box;margin:4px 0 6px 0;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px} `; document.head.appendChild(st); } function getStoredKhoaId(defaultId) { try { return localStorage.getItem('bsnt_khoa_dashboard') || defaultId; } catch(_) { return defaultId; } } function setStoredKhoaId(id) { try { localStorage.setItem('bsnt_khoa_dashboard', String(id)); } catch(_) {} } function createKhoaSelect(opts) { ensureStyles(); const { container, onChange } = opts || {}; const wrap = document.createElement('span'); wrap.className = 'dr-khoa-select-wrap'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'dr-khoa-select'; btn.id = 'dr-khoa-select'; const label = document.createElement('span'); label.textContent = 'Chọn khoa'; const caret = document.createElement('i'); caret.className = 'dr-khoa-caret'; btn.appendChild(label); btn.appendChild(caret); const menu = document.createElement('div'); menu.className = 'dr-khoa-menu'; menu.style.display = 'none'; const search = document.createElement('input'); search.className = 'dr-khoa-search'; search.placeholder = 'Tìm khoa...'; const listBox = document.createElement('div'); menu.appendChild(search); menu.appendChild(listBox); wrap.appendChild(btn); wrap.appendChild(menu); if (container) container.innerHTML = '', container.appendChild(wrap); let allKhoa = []; let currentId = getStoredKhoaId('551'); function renderList(filter='') { listBox.innerHTML = ''; const f = filter.trim().toLowerCase(); allKhoa .filter(k => !f || (k.name||'').toLowerCase().includes(f) || String(k.id).includes(f)) .forEach(k => { const item = document.createElement('div'); item.className = 'dr-khoa-item' + (String(k.id) === String(currentId) ? ' dr-khoa-active' : ''); item.textContent = `${k.name || 'Khoa'} (${k.id})`; item.addEventListener('click', () => { currentId = String(k.id); setStoredKhoaId(currentId); label.textContent = k.name || `Khoa ${k.id}`; if (typeof onChange === 'function') onChange(currentId, k.name || `Khoa ${k.id}`); menu.style.display = 'none'; }); listBox.appendChild(item); }); } async function init() { try { const list = await ApiService.fetchKhoaPhong(); allKhoa = Array.isArray(list) ? list : []; const found = allKhoa.find(k => String(k.id) === String(currentId)); label.textContent = (found && found.name) || `Khoa ${currentId}`; renderList(); } catch(_) { label.textContent = `Khoa ${currentId}`; } } btn.addEventListener('click', async (e) => { e.stopPropagation(); if (menu.style.display === 'none') { if (!allKhoa.length) await init(); menu.style.display = 'block'; search.focus(); } else { menu.style.display = 'none'; } }); search.addEventListener('input', () => renderList(search.value || '')); document.addEventListener('click', (e) => { if (!wrap.contains(e.target)) menu.style.display = 'none'; }); init(); return { el: wrap, button: btn, refreshLabel: init, getKhoaId: () => currentId }; } module.exports = { createKhoaSelect }; },{"../services/apiService":26}],10:[function(require,module,exports){ // components/listView.js - Rendering for list view rows and actions const Utils = require('../utils'); const PatientDataMapper = require('../utils/patientDataMapper'); const { createYLenhTags, hasMedsDoneToday } = require('../utils/tagUtils'); const { escapeHtml } = require('../utils/htmlUtils'); const { createCopyOneButton, createToDieuTriButton, createHsbaButton } = require('./actionButtons'); function createListActions(item, onCopy) { const wrap = document.createElement('div'); wrap.className = 'dr-list-actions'; // Copy icon for single-patient report (left-most as requested) const btnCopy = createCopyOneButton({ item, variant: 'icon' }); wrap.appendChild(btnCopy); // Document icon for Tờ điều trị const btnToDieuTri = createToDieuTriButton({ item, variant: 'icon' }); wrap.appendChild(btnToDieuTri); // Eye icon for HSBA V2 (right-most) const btnHsba = createHsbaButton({ item, variant: 'icon' }); wrap.appendChild(btnHsba); return wrap; } function createListRow(item, opts = {}) { const row = document.createElement('div'); row.className = 'dr-list-row'; const age = Utils.calculateAge(item.ngaysinh); const gender = item.phai === 1 ? 'Nữ' : 'Nam'; const formattedLocation = PatientDataMapper.formatRoomLocation( item.teN_PHONG, item.teN_GIUONG, item.teN_TANG, item.teN_TOANHA ); const { composeDiagnosis } = require('../utils/domUpdaters'); const { baseText: dx } = composeDiagnosis(item); const hxtText = (item.checklistState && item.checklistState.huongXuTri) ? String(item.checklistState.huongXuTri).trim() : ''; const left = document.createElement('div'); left.innerHTML = ` <div class="dr-list-title">${item.hoten || ''} <span class="dr-list-dem">- ${age}t - ${gender}</span> • <span class="dr-list-mabn">${item.mabn || ''}</span> • <span class="dr-list-loc">${formattedLocation}</span></div> <div class="dr-list-dx">${dx}</div> ${hxtText ? `<div class="dr-value dr-hxt-block"><span class="dr-label"><b>HXT:</b></span> ${escapeHtml(hxtText)}</div>` : ''} ${createYLenhTags(item)} `; const onCopy = async () => { try { const ReportService = require('../services/reportService'); const ChecklistService = require('../services/checklistService'); const { copyReportToClipboardRich } = require('../pages/page.dashboard.support'); const res = await ChecklistService.loadChecklistData(item); const obj = ChecklistService.findChecklistObject(res); const state = obj ? (ChecklistService.parseChecklistState(obj) || {}) : {}; const html = ReportService.generateSingleHTML(item, state); const text = ReportService.generateSingleText(item, state); await copyReportToClipboardRich(html, text); } catch (err) { console.error('Copy single-patient report failed:', err); } }; const right = createListActions(item, onCopy); row.appendChild(left); row.appendChild(right); if (typeof opts.onOpen === 'function') { row.addEventListener('click', opts.onOpen); } return row; } // escapeHtml now provided by utils/htmlUtils module.exports = { createListRow }; },{"../pages/page.dashboard.support":21,"../services/checklistService":27,"../services/reportService":29,"../utils":34,"../utils/domUpdaters":37,"../utils/htmlUtils":38,"../utils/patientDataMapper":40,"../utils/tagUtils":42,"./actionButtons":4}],11:[function(require,module,exports){ // loginHandler.js - Centralized login prompt handling const LoginHandler = { /** * Show login prompt and redirect to login page */ showLoginPrompt() { document.body.innerHTML = ''; const loginDiv = document.createElement('div'); loginDiv.className = 'dr-login'; loginDiv.textContent = 'Vui lòng đăng nhập để xem dữ liệu'; document.body.appendChild(loginDiv); setTimeout(() => { window.location.href = '/Home/Login'; }, 500); }, /** * Check if user needs to login and handle accordingly */ handleLoginRequired() { this.showLoginPrompt(); } }; module.exports = LoginHandler; },{}],12:[function(require,module,exports){ // modalManager.js - Centralized modal/sidebar management let SidebarSession = null; try { SidebarSession = require('./sidebarSession'); } catch(_) {} const ModalManager = { /** * Create or get existing backdrop element */ getOrCreateBackdrop() { let backdrop = document.getElementById('dr-sidebar-backdrop'); if (!backdrop) { backdrop = document.createElement('div'); backdrop.id = 'dr-sidebar-backdrop'; backdrop.style = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.25);z-index:99999;'; document.body.appendChild(backdrop); } return backdrop; }, /** * Create or get existing sidebar element */ getOrCreateSidebar() { let sidebar = document.getElementById('dr-sidebar'); if (!sidebar) { sidebar = document.createElement('div'); sidebar.id = 'dr-sidebar'; document.body.appendChild(sidebar); } return sidebar; }, /** * Show modal with backdrop */ showModal(sidebar, backdrop) { backdrop.style.display = 'block'; sidebar.style.display = 'block'; }, /** * Hide modal and backdrop */ hideModal(sidebar, backdrop) { if (sidebar) sidebar.style.display = 'none'; backdrop.style.display = 'none'; try { if (SidebarSession && typeof SidebarSession.endSession === 'function') SidebarSession.endSession(); } catch(_) {} }, /** * Setup modal close handlers */ setupCloseHandlers(sidebar, backdrop) { const hideModal = () => this.hideModal(sidebar, backdrop); // Click backdrop to close backdrop.onclick = hideModal; // Close button const closeBtn = document.createElement('button'); closeBtn.textContent = 'Đóng'; closeBtn.style = 'position:absolute;top:12px;right:12px;background:#eee;border:none;border-radius:50%;width:32px;height:32px;font-size:18px;cursor:pointer;'; closeBtn.onclick = hideModal; return closeBtn; } }; module.exports = ModalManager; },{"./sidebarSession":15}],13:[function(require,module,exports){ // patientInfoSection.js const { setupYLenhHandlers } = require('./yLenhHandlers'); const { setupPhauThuatHandlers } = require('./phauThuatHandlers'); const ChecklistService = require('../services/checklistService'); const Utils = require('../utils'); const ReportService = require('../services/reportService'); function createPatientInfoSection(patient, quickYLenhActions) { const ctxId = (window.dr_sidebar_ctx && window.dr_sidebar_ctx.id) || `${patient.mabn}:${Date.now()}`; const info = document.createElement('div'); // Reuse report DOB/age formatter for consistency with dr-report-content const { dob, age } = ReportService.formatDateOfBirth(patient.ngaysinh); const gender = patient.phai === 1 ? 'Nữ' : 'Nam'; const room = patient.teN_PHONG || ''; const bed = patient.teN_GIUONG || ''; info.innerHTML = ` <h2 style="margin-top:0">${patient.hoten || ''} <span style="font-size:0.9em;color:#888;">${patient.mabn ? ' - ' + patient.mabn : ''}</span></h2> <div><b>DOB:</b> ${dob} (${age}) - ${gender} - ${room} - ${bed}</div> <div><b>Chẩn đoán:</b> <span id="dr-chandoan">${patient.chandoanvk || ''}</span></div> <div style="margin-top:8px; display:grid; grid-template-columns:max-content 1fr; align-items:start; column-gap:10px;"> <label for="dr-chandoan-kemtheo" style="margin:0;font-weight:600;line-height:1.4;font-size:12px;color:#555;">Bệnh đi kèm</label> <div style="display:flex;flex-direction:column;gap:4px;"> <textarea id="dr-chandoan-kemtheo" rows="2" placeholder="VD: THA, ĐTĐ type 2..." style="width:100%;padding:6px 8px;border:1px solid #90caf9;border-radius:4px;resize:vertical;font-size:12px;line-height:1.3;min-height:44px;box-shadow:0 0 0 2px rgba(25,118,210,0.12);outline:none;"></textarea> <div id="dr-chandoan-kemtheo-saved" style="display:none;color:#2e7d32;font-weight:600;">Đã lưu</div> </div> </div> <div style="margin-top:20px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;gap:12px;"> <h3 style="margin:0;">Thông tin phẫu thuật</h3> <button id="dr-show-pt-form" style="background:#1976d2;color:#fff;border:none;border-radius:6px;padding:8px 14px;cursor:pointer;font-size:0.9em;white-space:nowrap;">Thêm phẫu thuật</button> </div> <div id="dr-pt-log" style="max-height:200px;overflow-y:auto;border:1px solid #eee;padding:10px;border-radius:4px;background:#f9f9f9;"> <div style="color:#888;font-style:italic;">Chưa có phẫu thuật nào...</div> </div> </div> <div style="margin-top:20px;"> <h3 style="margin-bottom:10px;">Kế hoạch điều trị / Hướng xử trí</h3> <textarea id="dr-hxt-textarea" rows="3" placeholder="VD: Kháng sinh 7 ngày, dự kiến xuất viện 22/08, tái khám sau 1 tuần..." style="width:100%;padding:10px;border:1px solid #eee;border-radius:6px;resize:vertical;"></textarea> <div id="dr-hxt-saved" style="display:none;color:#2e7d32;font-weight:600;margin-top:4px;">Đã lưu</div> </div> <div style="margin-top:20px;"> <h3 style="margin-bottom:10px;">Log y lệnh</h3> <!-- Quick Action Buttons --> <div class="quick-ylenh-actions"> ${quickYLenhActions.map(action => ` <button class="quick-ylenh-btn" data-action="${action.label}" style="color: ${action.color}; border-color: ${action.color};"> <span class="icon">${action.icon}</span> <span class="text">${action.label}</span> </button> `).join('')} </div> <div style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px;"> <input type="text" id="dr-y-lenh-input" placeholder="Nhập y lệnh (VD: rút sonde tiểu)" style="padding:10px;border:1px solid #ddd;border-radius:4px;"> <button id="dr-add-y-lenh" style="padding:12px 16px;background:#1976d2;color:#fff;border:none;border-radius:4px;cursor:pointer;width:100%;">Thêm</button> </div> <div id="dr-y-lenh-log" style="max-height:200px;overflow-y:auto;border:1px solid #eee;padding:10px;border-radius:4px;background:#f9f9f9;word-break: break-word; overflow-wrap: anywhere;"> <div style="color:#888;font-style:italic;">Chưa có y lệnh nào...</div> </div> </div> `; // Setup y lệnh functionality setupYLenhHandlers(info, patient); // Setup phẫu thuật functionality setupPhauThuatHandlers(info, patient); // Setup HXT (kế hoạch điều trị) auto-save and live update const hxtTextarea = info.querySelector('#dr-hxt-textarea'); const hxtSaved = info.querySelector('#dr-hxt-saved'); // Setup Chẩn đoán kèm theo auto-save const cdktTextarea = info.querySelector('#dr-chandoan-kemtheo'); const cdktSaved = info.querySelector('#dr-chandoan-kemtheo-saved'); // Shared debounced save state let pendingSaveTimer = null; const SAVE_DEBOUNCE_MS = 700; // Track last saved values to avoid redundant saves const lastSaved = { hxt: (patient && patient.checklistState && typeof patient.checklistState.huongXuTri === 'string') ? String(patient.checklistState.huongXuTri).trim() : '', cdkt: (patient && patient.checklistState && typeof patient.checklistState.chanDoanKemTheo === 'string') ? String(patient.checklistState.chanDoanKemTheo).trim() : '' }; // Current draft values const draft = { hxt: lastSaved.hxt, cdkt: lastSaved.cdkt }; let dirty = { hxt: false, cdkt: false }; // Initial load from patient-scoped state only (avoid leaking previous patient's global state) setTimeout(() => { if (patient && patient.checklistState && typeof patient.checklistState.huongXuTri === 'string') { hxtTextarea.value = patient.checklistState.huongXuTri; } if (patient && patient.checklistState && typeof patient.checklistState.chanDoanKemTheo === 'string') { cdktTextarea.value = patient.checklistState.chanDoanKemTheo; } }, 50); function invokeUpdatePatientCardHXT(p) { try { if (typeof updatePatientCardHXT === 'function') { updatePatientCardHXT(p); return true; } if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.updatePatientCardHXT === 'function') { unsafeWindow.updatePatientCardHXT(p); return true; } if (typeof globalThis !== 'undefined' && typeof globalThis.updatePatientCardHXT === 'function') { globalThis.updatePatientCardHXT(p); return true; } if (typeof this !== 'undefined' && typeof this.updatePatientCardHXT === 'function') { this.updatePatientCardHXT(p); return true; } if (typeof window !== 'undefined' && typeof window.updatePatientCardHXT === 'function') { window.updatePatientCardHXT(p); return true; } } catch (e) { console.warn('invokeUpdatePatientCardHXT error', e); } return false; } function softUpdateHXT(newVal) { // Update in-memory state and card immediately for UX if (!window.checklistState) window.checklistState = {}; window.checklistState = { ...(window.checklistState || {}), huongXuTri: newVal }; patient.checklistState = { ...(patient.checklistState || {}), huongXuTri: newVal }; if (window.dr_data && patient.mabn) { const patientInData = window.dr_data.find(p => p.mabn === patient.mabn); if (patientInData) { patientInData.checklistState = { ...(patientInData.checklistState || {}), huongXuTri: newVal }; } } invokeUpdatePatientCardHXT(patient); } async function persistIfDirty() { // Build a single save payload only if something actually changed const changedKeys = []; if (dirty.hxt && draft.hxt !== lastSaved.hxt) changedKeys.push('hxt'); if (dirty.cdkt && draft.cdkt !== lastSaved.cdkt) changedKeys.push('cdkt'); if (changedKeys.length === 0) return; if (!window.checklistState) window.checklistState = {}; const nextState = { ...window.checklistState }; if (changedKeys.includes('hxt')) nextState.huongXuTri = draft.hxt; if (changedKeys.includes('cdkt')) nextState.chanDoanKemTheo = draft.cdkt; // Persist once if (window.checklistObj) { const res = await ChecklistService.updateChecklistState(window.checklistObj, nextState, { ctxId, enqueueOnOffline: true, signal: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.signal) }); if (!res || (!res.ok && !res.queued)) { console.warn('Lưu checklist thất bại'); } else { // If this sidebar is no longer active, do not apply visual updates if (window.dr_sidebar_ctx && window.dr_sidebar_ctx.id !== ctxId) return; // Update global state snapshot and lastSaved window.checklistState = nextState; if (changedKeys.includes('hxt')) lastSaved.hxt = draft.hxt; if (changedKeys.includes('cdkt')) lastSaved.cdkt = draft.cdkt; dirty = { hxt: false, cdkt: false }; // Subtle flash effect on saved fields try { const flash = (el) => { if (!el) return; const prev = el.style.boxShadow; el.style.boxShadow = '0 0 0 2px rgba(76,175,80,0.6)'; setTimeout(() => { el.style.boxShadow = prev || ''; }, 400); }; if (changedKeys.includes('hxt')) { flash(hxtTextarea); if (hxtSaved) { hxtSaved.style.display = 'block'; setTimeout(() => hxtSaved.style.display = 'none', 600); } } if (changedKeys.includes('cdkt')) { flash(cdktTextarea); if (cdktSaved) { cdktSaved.style.display = 'block'; setTimeout(() => cdktSaved.style.display = 'none', 600); } // Update card diagnosis after saving CDKT to keep cards in sync try { if (typeof updatePatientCardCDKT === 'function') { updatePatientCardCDKT(patient); } else if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.updatePatientCardCDKT === 'function') { unsafeWindow.updatePatientCardCDKT(patient); } else if (typeof globalThis !== 'undefined' && typeof globalThis.updatePatientCardCDKT === 'function') { globalThis.updatePatientCardCDKT(patient); } } catch (_) {} } } catch (_) {} if (res && res.queued) { try { (window.showToast || console.log)("Đã lưu tạm—sẽ đồng bộ khi có mạng."); } catch(_) {} } } } } function scheduleSave() { if (pendingSaveTimer) clearTimeout(pendingSaveTimer); pendingSaveTimer = setTimeout(() => { pendingSaveTimer = null; persistIfDirty(); }, SAVE_DEBOUNCE_MS); } hxtTextarea.addEventListener('input', () => { const val = hxtTextarea.value.trim(); draft.hxt = val; // Mark dirty only if actual change relative to last saved dirty.hxt = (val !== lastSaved.hxt); softUpdateHXT(val); scheduleSave(); }); hxtTextarea.addEventListener('blur', () => { if (pendingSaveTimer) { clearTimeout(pendingSaveTimer); pendingSaveTimer = null; } // Save only if dirty to avoid redundant saves on focus/blur without edits persistIfDirty(); }); // ====== Chẩn đoán kèm theo: soft update + shared save ====== function softUpdateCDKT(newVal) { if (!window.checklistState) window.checklistState = {}; window.checklistState = { ...(window.checklistState || {}), chanDoanKemTheo: newVal }; patient.checklistState = { ...(patient.checklistState || {}), chanDoanKemTheo: newVal }; if (window.dr_data && patient.mabn) { const patientInData = window.dr_data.find(p => p.mabn === patient.mabn); if (patientInData) { patientInData.checklistState = { ...(patientInData.checklistState || {}), chanDoanKemTheo: newVal }; } } } cdktTextarea.addEventListener('input', () => { const val = cdktTextarea.value.trim(); draft.cdkt = val; dirty.cdkt = (val !== lastSaved.cdkt); softUpdateCDKT(val); scheduleSave(); }); cdktTextarea.addEventListener('blur', () => { if (pendingSaveTimer) { clearTimeout(pendingSaveTimer); pendingSaveTimer = null; } persistIfDirty(); }); return info; } module.exports = { createPatientInfoSection }; },{"../services/checklistService":27,"../services/reportService":29,"../utils":34,"./phauThuatHandlers":14,"./yLenhHandlers":16}],14:[function(require,module,exports){ // phauThuatHandlers.js const ChecklistService = require('../services/checklistService'); const BS_CAI_DAT = require('../BS_CAI_DAT_GIAO_DIEN'); const { updatePatientCardPhauThuat } = require('../utils/surgeryUtils'); function createDoctorCheckboxes(className) { return BS_CAI_DAT.danhSachBacSi.map(doctor => `<label><input type="checkbox" class="${className}" value="${doctor}"> ${doctor}</label>` ).join(''); } function setupPhauThuatHandlers(infoElement, patient) { const showFormBtn = infoElement.querySelector('#dr-show-pt-form'); const logContainer = infoElement.querySelector('#dr-pt-log'); function createPhauThuatPopup(editIndex = null) { // Check if popup already exists const existingPopup = document.getElementById('dr-pt-popup-backdrop'); if (existingPopup) { console.log('Popup already exists, skipping creation'); return; } const backdrop = document.createElement('div'); backdrop.id = 'dr-pt-popup-backdrop'; backdrop.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 100001; display: flex; align-items: center; justify-content: center; `; const popup = document.createElement('div'); popup.id = 'dr-pt-popup'; popup.style.cssText = ` background: white; border-radius: 8px; padding: 24px; max-width: 500px; width: 90vw; max-height: 80vh; overflow-y: auto; box-shadow: 0 4px 20px rgba(0,0,0,0.3); `; popup.innerHTML = ` <h3 style="margin-top: 0; margin-bottom: 16px;">${editIndex !== null ? 'Sửa thông tin phẫu thuật' : 'Thêm thông tin phẫu thuật'}</h3> <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;"> <div> <label style="font-size:0.9em;color:#666;">Ngày PT:</label> <input type="text" id="dr-pt-date-popup" placeholder="dd/mm/yyyy" style="width:100%;padding:6px;border:1px solid #ddd;border-radius:4px;"> </div> <div> <label style="font-size:0.9em;color:#666;">Giờ PT:</label> <div style="display:flex;gap:4px;align-items:center;"> <input type="number" id="dr-pt-hour-popup" min="0" max="23" placeholder="HH" style="width:50px;padding:6px;border:1px solid #ddd;border-radius:4px;text-align:center;" /> <span style="font-weight:bold;">:</span> <input type="number" id="dr-pt-minute-popup" min="0" max="59" step="5" placeholder="MM" style="width:50px;padding:6px;border:1px solid #ddd;border-radius:4px;text-align:center;" /> <small style="margin-left:8px;color:#888;">(24h)</small> </div> </div> </div> <div style="margin-bottom:12px;"> <label style="font-size:0.9em;color:#666;">Phương pháp phẫu thuật (PPPT):</label> <input type="text" id="dr-pt-method-popup" placeholder="Nhập PPPT" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;"> </div> <div style="margin-bottom:16px;"> <label style="font-size:0.9em;color:#666;margin-bottom:6px;display:block;">Bác sĩ thực hiện:</label> <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:0.9em;"> ${createDoctorCheckboxes('dr-pt-doctor-popup')} </div> </div> <div style="display: flex; gap: 8px; justify-content: flex-end;"> <button id="dr-cancel-pt" style="background:#666;color:#fff;border:none;border-radius:4px;padding:8px 16px;cursor:pointer;">Hủy</button> <button id="dr-save-pt" style="background:${BS_CAI_DAT.colors.primary};color:#fff;border:none;border-radius:4px;padding:8px 16px;cursor:pointer;">Lưu</button> </div> `; backdrop.appendChild(popup); document.body.appendChild(backdrop); const originalClosePopup = function() { document.body.removeChild(backdrop); document.documentElement.lang = originalLang || 'vi'; }; const dateInput = popup.querySelector('#dr-pt-date-popup'); const hourInput = popup.querySelector('#dr-pt-hour-popup'); const minuteInput = popup.querySelector('#dr-pt-minute-popup'); const methodInput = popup.querySelector('#dr-pt-method-popup'); const doctorCheckboxes = popup.querySelectorAll('.dr-pt-doctor-popup'); const saveBtn = popup.querySelector('#dr-save-pt'); const cancelBtn = popup.querySelector('#dr-cancel-pt'); hourInput.addEventListener('input', function() { let value = parseInt(this.value); if (value > 23) this.value = 23; if (value < 0) this.value = 0; if (this.value.length === 2) { minuteInput.focus(); minuteInput.select(); } }); hourInput.addEventListener('focus', function() { this.select(); }); minuteInput.addEventListener('input', function() { let value = parseInt(this.value); if (value > 59) this.value = 59; if (value < 0) this.value = 0; }); minuteInput.addEventListener('focus', function() { this.select(); }); minuteInput.addEventListener('blur', function() { if (this.value && this.value.length === 1) { this.value = '0' + this.value; } }); hourInput.addEventListener('blur', function() { if (this.value && this.value.length === 1) { this.value = '0' + this.value; } }); const config = BS_CAI_DAT.phauThuatDefaults; // Load existing data for edit mode if (editIndex !== null && window.checklistState.phauThuatLog && window.checklistState.phauThuatLog[editIndex]) { const editData = window.checklistState.phauThuatLog[editIndex]; dateInput.value = editData.date || ''; methodInput.value = editData.method || ''; // Parse time if (editData.time) { const [hour, minute] = editData.time.split(':'); hourInput.value = hour; minuteInput.value = minute; } // Set doctors if (editData.doctors) { const doctorList = editData.doctors.split(', '); doctorCheckboxes.forEach(cb => { cb.checked = doctorList.includes(cb.value); }); } } else { // Set defaults for new entry if (config.defaultDate === 'tomorrow') { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const tomorrowStr = String(tomorrow.getDate()).padStart(2, '0') + '/' + String(tomorrow.getMonth() + 1).padStart(2, '0') + '/' + tomorrow.getFullYear(); dateInput.value = tomorrowStr; } else if (config.defaultDate === 'today') { const today = new Date(); const todayStr = String(today.getDate()).padStart(2, '0') + '/' + String(today.getMonth() + 1).padStart(2, '0') + '/' + today.getFullYear(); dateInput.value = todayStr; } if (config.defaultTime) { const [defaultHour, defaultMinute] = config.defaultTime.split(':'); hourInput.value = defaultHour; minuteInput.value = defaultMinute; } } function closePopup() { originalClosePopup(); } function savePhauThuat() { const date = dateInput.value.trim(); const hour = hourInput.value.trim(); const minute = minuteInput.value.trim(); const method = methodInput.value.trim(); let time = ''; if (hour && minute) { const h = parseInt(hour, 10); const m = parseInt(minute, 10); if (h >= 0 && h <= 23 && m >= 0 && m <= 59) { time = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'); } else { alert('Thời gian không hợp lệ. Giờ: 0-23, Phút: 0-59'); return; } } else if (hour || minute) { alert('Vui lòng nhập đầy đủ giờ và phút'); return; } if (!date || !time || !method) { alert(BS_CAI_DAT.validation.messages.missingPhauThuatInfo); return; } const dateRegex = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/; const dateMatch = date.match(dateRegex); if (!dateMatch) { alert(BS_CAI_DAT.validation.messages.invalidDateFormat); return; } const day = parseInt(dateMatch[1]); const month = parseInt(dateMatch[2]); const year = parseInt(dateMatch[3]); if (month < 1 || month > 12) { alert(BS_CAI_DAT.validation.messages.invalidMonth); return; } if (day < 1 || day > 31) { alert(BS_CAI_DAT.validation.messages.invalidDay); return; } const dateObj = new Date(year, month - 1, day); if (dateObj.getDate() !== day || dateObj.getMonth() !== month - 1 || dateObj.getFullYear() !== year) { alert(BS_CAI_DAT.validation.messages.invalidDate); return; } const selectedDoctors = Array.from(doctorCheckboxes) .filter(cb => cb.checked) .map(cb => cb.value); if (selectedDoctors.length === 0) { alert(BS_CAI_DAT.validation.messages.noDoctorSelected); return; } if (!window.checklistState.phauThuatLog) { window.checklistState.phauThuatLog = []; } const newEntry = { date: date, time: time, method: method, doctors: selectedDoctors.join(', '), id: Date.now() }; if (editIndex !== null) { // Update existing entry window.checklistState.phauThuatLog[editIndex] = newEntry; } else { // Add new entry at the beginning window.checklistState.phauThuatLog.unshift(newEntry); } savePhauThuatLog(); renderPhauThuatLog(window.checklistState.phauThuatLog); updatePatientCardPhauThuatLocal(patient); closePopup(); } saveBtn.addEventListener('click', savePhauThuat); cancelBtn.addEventListener('click', closePopup); backdrop.addEventListener('click', function(e) { if (e.target === backdrop) { closePopup(); } }); } function loadPhauThuatLog() { if (window.checklistState && window.checklistState.phauThuatLog) { renderPhauThuatLog(window.checklistState.phauThuatLog); } } function renderPhauThuatLog(phauThuatArray) { if (!Array.isArray(phauThuatArray) || phauThuatArray.length === 0) { logContainer.innerHTML = '<div style="color:#888;font-style:italic;">Chưa có phẫu thuật nào...</div>'; return; } logContainer.innerHTML = phauThuatArray.map((entry, index) => ` <div class="pt-entry-clickable" data-index="${index}" style="margin-bottom:8px;padding:8px 40px 8px 8px;background:#fff;border-radius:4px;border-left:3px solid #4caf50;position:relative;cursor:pointer;transition:background-color 0.2s;" onmouseover="this.style.backgroundColor='#f5f5f5'" onmouseout="this.style.backgroundColor='#fff'"> <button class="remove-pt-btn" data-index="${index}" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:#d32f2f;color:#fff;border:none;border-radius:3px;padding:2px 6px;font-size:0.8em;cursor:pointer;z-index:1;">Xóa</button> <div style="font-size:0.9em;color:#666;margin-bottom:4px;"><strong>Ngày PT:</strong> ${entry.date} ${entry.time}</div> <div style="font-weight:bold;color:#333;margin-bottom:2px;"><strong>PPPT:</strong> ${entry.method}</div> <div style="font-size:0.85em;color:#555;"><strong>BS:</strong> ${entry.doctors}</div> </div> `).join(''); setTimeout(() => { logContainer.querySelectorAll('.remove-pt-btn').forEach(btn => { btn.addEventListener('click', function(e) { e.stopPropagation(); const index = parseInt(this.getAttribute('data-index')); removePhauThuat(index); }); }); logContainer.querySelectorAll('.pt-entry-clickable').forEach(entry => { entry.addEventListener('click', function(e) { if (e.target.classList.contains('remove-pt-btn')) return; const index = parseInt(this.getAttribute('data-index')); editPhauThuat(index); }); }); }, 10); } function editPhauThuat(index) { console.log('Edit phẫu thuật:', index); createPhauThuatPopup(index); } function removePhauThuat(index) { if (window.checklistState.phauThuatLog && Array.isArray(window.checklistState.phauThuatLog)) { window.checklistState.phauThuatLog.splice(index, 1); savePhauThuatLog(); renderPhauThuatLog(window.checklistState.phauThuatLog); updatePatientCardPhauThuatLocal(patient); } } async function savePhauThuatLog() { if (window.checklistObj) { const res = await ChecklistService.updateChecklistState(window.checklistObj, window.checklistState, { enqueueOnOffline: true, ctxId: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.id), signal: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.signal) }); if (!res || (!res.ok && !res.queued)) { console.error('Lưu log phẫu thuật thất bại!'); } } } function updatePatientCardPhauThuatLocal(patient) { updatePatientCardPhauThuat(patient); // Also try global access as fallback if (typeof unsafeWindow !== 'undefined' && unsafeWindow.updatePatientCardPhauThuat) { unsafeWindow.updatePatientCardPhauThuat(patient); } else if (typeof this !== 'undefined' && this.updatePatientCardPhauThuat) { this.updatePatientCardPhauThuat(patient); } else if (globalThis.updatePatientCardPhauThuat) { globalThis.updatePatientCardPhauThuat(patient); } else if (window.updatePatientCardPhauThuat) { window.updatePatientCardPhauThuat(patient); } } showFormBtn.addEventListener('click', () => createPhauThuatPopup(null)); setTimeout(loadPhauThuatLog, 100); window.currentRemovePhauThuat = removePhauThuat; window.currentRenderPhauThuatLog = renderPhauThuatLog; window.currentEditPhauThuat = editPhauThuat; } module.exports = { setupPhauThuatHandlers }; },{"../BS_CAI_DAT_GIAO_DIEN":1,"../services/checklistService":27,"../utils/surgeryUtils":41}],15:[function(require,module,exports){ // sidebarSession.js - Manage per-sidebar session context and AbortController let _current = { id: 0, mabn: null, controller: null }; const SidebarSession = { startSession(mabn) { // End previous session try { if (_current.controller) _current.controller.abort(); } catch(_) {} _current.id = Date.now(); _current.mabn = mabn || null; _current.controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; return _current.id; }, endSession() { try { if (_current.controller) _current.controller.abort(); } catch(_) {} _current.controller = null; _current.mabn = null; _current.id = 0; }, getSignal() { return _current.controller ? _current.controller.signal : undefined; }, isActive(sessionId) { return !!sessionId && sessionId === _current.id; }, getCurrent() { return { ..._current }; } }; module.exports = SidebarSession; },{}],16:[function(require,module,exports){ // yLenhHandlers.js const ChecklistService = require('../services/checklistService'); const BS_CAI_DAT = require('../BS_CAI_DAT_GIAO_DIEN'); function setupYLenhHandlers(infoElement, patient) { const ctxId = (window.dr_sidebar_ctx && window.dr_sidebar_ctx.id) || `${patient.mabn}:${Date.now()}`; const input = infoElement.querySelector('#dr-y-lenh-input'); const addBtn = infoElement.querySelector('#dr-add-y-lenh'); const logContainer = infoElement.querySelector('#dr-y-lenh-log'); // Use configured quick y lệnh actions const quickYLenhActions = BS_CAI_DAT.quickYLenhActions; // Load existing y lệnh when checklist is loaded function loadYLenhLog() { if (window.checklistState && window.checklistState.yLenhLog) { renderYLenhLog(window.checklistState.yLenhLog); } } // Render y lệnh log (filter out quick actions from display) function renderYLenhLog(yLenhArray) { if (!Array.isArray(yLenhArray) || yLenhArray.length === 0) { logContainer.innerHTML = '<div style="color:#888;font-style:italic;">Chưa có y lệnh nào...</div>'; return; } // Filter out quick actions for log display only const quickActionLabels = BS_CAI_DAT.quickYLenhActions.map(action => action.label); const manualEntries = yLenhArray.filter(entry => !quickActionLabels.includes(entry.content)); if (manualEntries.length === 0) { logContainer.innerHTML = '<div style="color:#888;font-style:italic;">Chưa có y lệnh manual nào...</div>'; return; } logContainer.innerHTML = manualEntries.map((entry, index) => { // Find original index in full array for correct removal const originalIndex = yLenhArray.findIndex(originalEntry => originalEntry.id === entry.id || (originalEntry.timestamp === entry.timestamp && originalEntry.content === entry.content) ); return ` <div style="margin-bottom:8px;padding:8px 40px 8px 8px;background:#fff;border-radius:4px;border-left:3px solid #1976d2;position:relative;word-break: break-word; overflow-wrap: anywhere;"> <button class="remove-y-lenh-btn" data-index="${originalIndex}" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:#d32f2f;color:#fff;border:none;border-radius:3px;padding:2px 6px;font-size:0.8em;cursor:pointer;">Xóa</button> <div style="font-size:0.9em;color:#666;margin-bottom:4px;">${entry.timestamp}</div> <div style="font-weight:bold;color:#333;">${entry.content}</div> </div> `; }).join(''); // Add event listeners for remove buttons setTimeout(() => { logContainer.querySelectorAll('.remove-y-lenh-btn').forEach(btn => { btn.addEventListener('click', function() { const index = parseInt(this.getAttribute('data-index')); removeYLenh(index); }); }); }, 10); } // Add y lệnh (enhanced với support cho quick actions) function addYLenh(content = null) { const inputContent = content || input.value.trim(); if (!inputContent) return; // Initialize yLenhLog if not exists if (!window.checklistState.yLenhLog) { window.checklistState.yLenhLog = []; } // Create new entry const now = new Date(); const timestamp = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; const doctorName = 'BS'; // You can customize this to get actual doctor name const newEntry = { timestamp: `${timestamp} - ${doctorName}`, content: inputContent, id: Date.now() // Unique ID for easier removal }; // Add to array window.checklistState.yLenhLog.unshift(newEntry); // Add to beginning for newest first // Save to server saveYLenhLog(); // Clear input and re-render if (!content) input.value = ''; // Only clear if not from quick action renderYLenhLog(window.checklistState.yLenhLog); // Update patient object in window.dr_data with new checklistState if (window.dr_data && patient.mabn) { const patientInData = window.dr_data.find(p => p.mabn === patient.mabn); if (patientInData) { patientInData.checklistState = { ...window.checklistState }; console.log('Updated checklistState in window.dr_data for patient:', patient.mabn); } } // Trigger patient card update to show new tag if (window.updatePatientCardTags) { console.log('Calling updatePatientCardTags for patient:', patient.mabn); window.updatePatientCardTags(patient.mabn); } // Also check celebration animation specifically after adding tag setTimeout(() => { if (typeof window.checkAllCelebrationAnimations === 'function') { // Find the updated patient data const patientInData = window.dr_data.find(p => p.mabn === patient.mabn); if (patientInData) { window.checkAllCelebrationAnimations([patientInData]); } } }, 100); } // Remove y lệnh function removeYLenh(index) { if (window.checklistState.yLenhLog && Array.isArray(window.checklistState.yLenhLog)) { window.checklistState.yLenhLog.splice(index, 1); saveYLenhLog(); renderYLenhLog(window.checklistState.yLenhLog); // Update patient object in window.dr_data with new checklistState if (window.dr_data && patient.mabn) { const patientInData = window.dr_data.find(p => p.mabn === patient.mabn); if (patientInData) { patientInData.checklistState = { ...window.checklistState }; console.log('Updated checklistState in window.dr_data after removal for patient:', patient.mabn); } } // Trigger patient card update to refresh tags if (window.updatePatientCardTags) { console.log('Calling updatePatientCardTags after removal for patient:', patient.mabn); window.updatePatientCardTags(patient.mabn); } // Also check celebration animation specifically after removing tag setTimeout(() => { if (typeof window.checkAllCelebrationAnimations === 'function') { // Find the updated patient data const patientInData = window.dr_data.find(p => p.mabn === patient.mabn); if (patientInData) { window.checkAllCelebrationAnimations([patientInData]); } } }, 100); } } // Save y lệnh log to server async function saveYLenhLog() { if (window.checklistObj) { const res = await ChecklistService.updateChecklistState(window.checklistObj, window.checklistState, { ctxId, enqueueOnOffline: true, signal: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.signal) }); if (!res || (!res.ok && !res.queued)) { console.error('Lưu log y lệnh thất bại!'); } if (res && res.queued) { try { (window.showToast || console.log)("Đã lưu tạm—sẽ đồng bộ khi có mạng."); } catch(_) {} } } } // Event listeners addBtn.addEventListener('click', () => addYLenh()); input.addEventListener('keypress', function(e) { if (e.key === 'Enter') { addYLenh(); } }); // Helper: find today's quick entry by action function findTodayQuickEntryByAction(actionText) { const today = new Date(); const todayStr = `${today.getDate().toString().padStart(2, '0')}/${(today.getMonth() + 1).toString().padStart(2, '0')}/${today.getFullYear()}`; if (!window.checklistState || !Array.isArray(window.checklistState.yLenhLog)) return { entry: null, index: -1 }; const index = window.checklistState.yLenhLog.findIndex(e => { const entryDate = e.timestamp ? e.timestamp.split(' ')[0] : ''; const sameAction = e.action ? e.action === actionText : e.content === actionText; return entryDate === todayStr && sameAction && (e.q === true || e.content === actionText); }); return { entry: index >= 0 ? window.checklistState.yLenhLog[index] : null, index }; } // UI: Discharge time editor (appears when 'Xuất viện' quick action is present today) function ensureDischargeTimeEditor() { const hostAfter = infoElement.querySelector('.quick-ylenh-actions'); if (!hostAfter) return; const { entry } = findTodayQuickEntryByAction('Xuất viện'); let editor = infoElement.querySelector('.xv-time-editor'); if (!entry) { if (editor) editor.remove(); return; } // ensure editor exists if (!editor) { editor = document.createElement('div'); editor.className = 'xv-time-editor'; editor.innerHTML = ` <label class="xv-label">Xuất viện lúc:</label> <input class="xv-time xv-hour" type="number" min="0" max="23" placeholder="HH" style="width:56px;text-align:center;" /> <span>:</span> <input class="xv-time xv-min" type="number" min="0" max="59" placeholder="mm" style="width:56px;text-align:center;" /> <div class="xv-presets" style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap;"> <button type="button" class="xv-chip" data-time="11:00" style="padding:2px 8px;border:1px solid #ccc;border-radius:12px;background:#f7f7f7;cursor:pointer;">11:00</button> <button type="button" class="xv-chip" data-time="12:00" style="padding:2px 8px;border:1px solid #ccc;border-radius:12px;background:#f7f7f7;cursor:pointer;">12:00</button> <button type="button" class="xv-chip" data-time="13:00" style="padding:2px 8px;border:1px solid #ccc;border-radius:12px;background:#f7f7f7;cursor:pointer;">13:00</button> <button type="button" class="xv-chip" data-time="14:00" style="padding:2px 8px;border:1px solid #ccc;border-radius:12px;background:#f7f7f7;cursor:pointer;">14:00</button> <button type="button" class="xv-chip" data-time="15:00" style="padding:2px 8px;border:1px solid #ccc;border-radius:12px;background:#f7f7f7;cursor:pointer;">15:00</button> </div> <span class="xv-saved" style="display:none;">Đã lưu</span> `; hostAfter.insertAdjacentElement('afterend', editor); } const hourInput = editor.querySelector('.xv-hour'); const minInput = editor.querySelector('.xv-min'); const presets = editor.querySelector('.xv-presets'); const saved = editor.querySelector('.xv-saved'); // default to 12:00 if missing if (!entry.dischargeTime) entry.dischargeTime = '12:00'; const [hh = '12', mm = '00'] = (entry.dischargeTime || '12:00').split(':'); if (hourInput.value !== hh) hourInput.value = hh; if (minInput.value !== mm) minInput.value = mm; // Bind change handlers once const commit = async () => { const { entry: current, index } = findTodayQuickEntryByAction('Xuất viện'); if (current && index >= 0) { let h = parseInt(hourInput.value, 10); let m = parseInt(minInput.value, 10); if (isNaN(h)) h = 12; if (isNaN(m)) m = 0; h = Math.max(0, Math.min(23, h)); m = Math.max(0, Math.min(59, m)); const hh2 = String(h).padStart(2, '0'); const mm2 = String(m).padStart(2, '0'); current.dischargeTime = `${hh2}:${mm2}`; // Update dr_data patient state for tag refresh if (window.dr_data && patient.mabn) { const patientInData = window.dr_data.find(p => p.mabn === patient.mabn); if (patientInData) patientInData.checklistState = { ...window.checklistState }; } await saveYLenhLog(); if (typeof window.updatePatientCardTags === 'function') { window.updatePatientCardTags(patient.mabn); } if (saved) { saved.style.display = 'inline'; setTimeout(() => { saved.style.display = 'none'; }, 1000); } } }; if (!hourInput._bound) { hourInput.addEventListener('change', commit); hourInput.addEventListener('blur', commit); hourInput._bound = true; } // UX: select all text in hour field when user clicks/focuses it if (!hourInput._selectAllBound) { const selectAll = (e) => { try { // Attempt twice to handle timing quirks e.target.select && e.target.select(); setTimeout(() => { try { e.target.select && e.target.select(); } catch (_) {} }, 0); } catch (_) { /* noop */ } }; hourInput.addEventListener('focus', selectAll); hourInput.addEventListener('click', selectAll); // Prevent mouseup from clearing the selection in some browsers hourInput.addEventListener('mouseup', (ev) => ev.preventDefault()); hourInput._selectAllBound = true; } if (!minInput._bound) { minInput.addEventListener('change', commit); minInput.addEventListener('blur', commit); minInput._bound = true; } // UX: select all text in minute field when user clicks/focuses it if (!minInput._selectAllBound) { const selectAllMin = (e) => { try { e.target.select && e.target.select(); setTimeout(() => { try { e.target.select && e.target.select(); } catch (_) {} }, 0); } catch (_) { /* noop */ } }; minInput.addEventListener('focus', selectAllMin); minInput.addEventListener('click', selectAllMin); minInput.addEventListener('mouseup', (ev) => ev.preventDefault()); minInput._selectAllBound = true; } // Preset chips: quick one-tap set and save if (presets && !presets._bound) { presets.querySelectorAll('.xv-chip').forEach(chip => { chip.addEventListener('click', async () => { const tm = chip.getAttribute('data-time') || '12:00'; const [hh3, mm3] = tm.split(':'); hourInput.value = hh3.padStart(2, '0'); minInput.value = mm3.padStart(2, '0'); await commit(); // brief visual press feedback chip.style.transform = 'scale(0.98)'; setTimeout(() => { chip.style.transform = ''; }, 120); }); }); presets._bound = true; } } // Quick action buttons event listeners - Toggle logic (3-state: off -> active -> done -> off) infoElement.querySelectorAll('.quick-ylenh-btn').forEach(btn => { btn.addEventListener('click', function() { const actionText = this.getAttribute('data-action'); toggleQuickYLenh(actionText, this); }); }); // Function to toggle quick y lệnh (three states) function toggleQuickYLenh(actionText, buttonElement) { // Today string const today = new Date(); const todayStr = `${today.getDate().toString().padStart(2, '0')}/${(today.getMonth() + 1).toString().padStart(2, '0')}/${today.getFullYear()}`; if (!window.checklistState.yLenhLog) { window.checklistState.yLenhLog = []; } // Find existing quick entry for this action today const existingIndex = window.checklistState.yLenhLog.findIndex(entry => { const entryDate = entry.timestamp ? entry.timestamp.split(' ')[0] : ''; const isToday = entryDate === todayStr; const isQuick = entry.q === true || (entry.content === actionText && !entry.id?.toString().startsWith('manual')); const sameAction = entry.action ? entry.action === actionText : entry.content === actionText; return isToday && isQuick && sameAction; }); // Cycle states if (existingIndex === -1) { // OFF -> ACTIVE (create entry) const now = new Date(); const timestamp = `${todayStr} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; const doctorName = 'BS'; const newEntry = { timestamp: `${timestamp} - ${doctorName}`, content: actionText, id: Date.now(), q: true, action: actionText, status: 'active' }; // If this is 'Xuất viện', set default discharge time if (actionText === 'Xuất viện') { newEntry.dischargeTime = '12:00'; } window.checklistState.yLenhLog.unshift(newEntry); buttonElement.classList.add('active'); buttonElement.classList.remove('done'); console.log('Quick action set to ACTIVE:', actionText); } else { const entry = window.checklistState.yLenhLog[existingIndex]; if (entry.status === 'active') { // ACTIVE -> DONE entry.status = 'done'; buttonElement.classList.remove('active'); buttonElement.classList.add('done'); console.log('Quick action set to DONE:', actionText); } else if (entry.status === 'done') { // DONE -> OFF (remove) window.checklistState.yLenhLog.splice(existingIndex, 1); buttonElement.classList.remove('active'); buttonElement.classList.remove('done'); console.log('Quick action reset to OFF:', actionText); } else { // Unknown status (fallback): set to ACTIVE entry.status = 'active'; buttonElement.classList.add('active'); buttonElement.classList.remove('done'); } } // Save changes saveYLenhLog(); renderYLenhLog(window.checklistState.yLenhLog); // Update patient object in window.dr_data if (window.dr_data && patient.mabn) { const patientInData = window.dr_data.find(p => p.mabn === patient.mabn); if (patientInData) { patientInData.checklistState = { ...window.checklistState }; } } // Update card tags (quick actions might render as tags; styles can reflect state) if (window.updatePatientCardTags) { window.updatePatientCardTags(patient.mabn); } // Discharge time editor + celebration animation for 'Xuất viện' if (actionText === 'Xuất viện') { // ensure time editor is visible/hidden appropriately ensureDischargeTimeEditor(); setTimeout(() => { if (typeof window.checkAllCelebrationAnimations === 'function') { const patientInData = window.dr_data.find(p => p.mabn === patient.mabn); if (patientInData) { window.checkAllCelebrationAnimations([patientInData]); } } }, 100); } // Visual feedback buttonElement.style.transform = 'scale(0.95)'; setTimeout(() => { buttonElement.style.transform = ''; }, 150); } // Function to update button states based on existing log function updateQuickActionButtonStates() { const today = new Date(); const todayStr = `${today.getDate().toString().padStart(2, '0')}/${(today.getMonth() + 1).toString().padStart(2, '0')}/${today.getFullYear()}`; infoElement.querySelectorAll('.quick-ylenh-btn').forEach(btn => { const actionText = btn.getAttribute('data-action'); // Check if this action exists today let state = 'off'; if (window.checklistState && Array.isArray(window.checklistState.yLenhLog)) { const found = window.checklistState.yLenhLog.find(entry => { const entryDate = entry.timestamp ? entry.timestamp.split(' ')[0] : ''; const isToday = entryDate === todayStr; const sameAction = entry.action ? entry.action === actionText : entry.content === actionText; return isToday && sameAction && (entry.q === true || entry.content === actionText); }); if (found) state = found.status || 'active'; } btn.classList.toggle('active', state === 'active'); btn.classList.toggle('done', state === 'done'); }); } // Load existing data after a short delay to ensure checklist is loaded setTimeout(() => { loadYLenhLog(); updateQuickActionButtonStates(); // Ensure discharge editor appears if needed on load ensureDischargeTimeEditor(); }, 100); // Store reference to removeYLenh for use in loadYLenhLogFromState window.currentRemoveYLenh = removeYLenh; // Return functions that might be needed externally return { loadYLenhLog, renderYLenhLog, addYLenh, removeYLenh, updateQuickActionButtonStates }; } module.exports = { setupYLenhHandlers }; },{"../BS_CAI_DAT_GIAO_DIEN":1,"../services/checklistService":27}],17:[function(require,module,exports){ // googleAppsScript.js function GoogleAppsScriptUploader(googleAppsScriptUrl) { this.url = googleAppsScriptUrl; } GoogleAppsScriptUploader.prototype.uploadPatientList = function () { if (!window.dr_data || !Array.isArray(window.dr_data) || window.dr_data.length === 0) { console.error('Không có dữ liệu bệnh nhân để upload lên Google Apps Script!'); return; } console.log('Đang tải dữ liệu bệnh nhân lên Google Apps Script (sử dụng GM_xmlhttpRequest để xử lý CORS)...'); var requestBody = { function: 'doPost', parameters: [window.dr_data], }; var self = this; return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: 'POST', url: self.url, headers: { 'Content-Type': 'application/json' }, redirects: 'follow', data: JSON.stringify(requestBody), onload: function (response) { try { console.log('Phản hồi từ Google Apps Script:', response); var result = JSON.parse(response.responseText); if (response.status >= 200 && response.status < 300) { console.log('Đã gửi danh sách bệnh nhân lên Google Apps Script thành công!'); console.log('Kết quả từ Google Apps Script:', result); resolve(result); } else { console.error('Lỗi khi gửi lên Google Apps Script: ' + (result.error && result.error.message ? result.error.message : 'HTTP Status ' + response.status)); console.error('Lỗi từ Google Apps Script:', result); reject(new Error(result.error && result.error.message ? result.error.message : 'HTTP Status ' + response.status)); } } catch (e) { console.error('Lỗi phân tích phản hồi từ Google Apps Script: ' + e.message); console.error('Lỗi phân tích phản hồi:', e, response.responseText); reject(new Error('Failed to parse response from Google Apps Script.')); } }, onerror: function (error) { console.error('Lỗi mạng khi gửi lên Google Apps Script: ' + error.statusText); console.error('Lỗi mạng (GM_xmlhttpRequest):', error); reject(new Error('Network error or failed to connect to Google Apps Script.')); }, ontimeout: function (error) { console.error('Yêu cầu tới Google Apps Script bị hết thời gian: ' + error.statusText); console.error('Yêu cầu hết thời gian (GM_xmlhttpRequest):', error); reject(new Error('Request to Google Apps Script timed out.')); } }); }); }; GoogleAppsScriptUploader.prototype.addUploadButton = function () { var btn = document.createElement('button'); btn.innerText = 'Đăng lên Google Sheet'; btn.style.position = 'fixed'; btn.style.left = '20px'; btn.style.bottom = '70px'; btn.style.background = '#4285F4'; btn.style.color = '#fff'; btn.style.padding = '10px 18px'; btn.style.border = 'none'; btn.style.borderRadius = '5px'; btn.style.fontSize = '14px'; btn.style.cursor = 'pointer'; btn.style.zIndex = 9999; btn.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)'; btn.onclick = this.uploadPatientList.bind(this); document.body.appendChild(btn); }; var GOOGLE_APPS_SCRIPT_URL = 'https://script.google.com/macros/s/AKfycbz48_viXo1mjhk-W2CsDbxLuFLFHyD2I7k2UchSmZROjqRC9S4hCRvbNmNOY5nP8HVBnA/exec'; module.exports = { GoogleAppsScriptUploader: GoogleAppsScriptUploader, GOOGLE_APPS_SCRIPT_URL: GOOGLE_APPS_SCRIPT_URL }; },{}],18:[function(require,module,exports){ // otm-entry.js - Entry point for OTM content script (function() { 'use strict'; // Only run on OTM domain if (window.location.hostname !== 'otm.tahospital.vn') { return; } console.log('OTM Entry Script loaded'); // Load the content script try { require('./otm.content.script'); } catch (error) { console.error('Failed to load OTM content script:', error); } })(); },{"./otm.content.script":19}],19:[function(require,module,exports){ // otm.content.js - Content script for OTM surgery data fetching (function() { 'use strict'; console.log('OTM Content Script loaded'); // Function to check if debug is enabled function isDebugEnabled() { return localStorage.getItem('dr_debug_otm') === 'true'; } // Function to log debug messages function debugLog(message, ...args) { if (isDebugEnabled()) { console.log('[OTM Debug]', message, ...args); } } // Lightweight validation for access tokens to avoid saving 'undefined'/'null'/too-short values function isLikelyValidToken(tok) { try { if (typeof tok !== 'string') return false; const t = tok.trim(); if (!t) return false; const low = t.toLowerCase(); if (low === 'undefined' || low === 'null') return false; if (t.length < 16) return false; // heuristic: tokens are typically long // avoid whitespace in token if (/\s/.test(t)) return false; return true; } catch { return false; } } // Function to save bearer token to GM and localStorage (only if valid) function saveBearerToken(token) { try { if (!isLikelyValidToken(token)) { debugLog('Refusing to save invalid bearer token candidate'); return; } try { sessionStorage.setItem('otm_bearer_token', token); } catch(_) {} try { localStorage.setItem('otm_bearer_token', token); } catch(_) {} try { if (typeof GM !== 'undefined' && GM.setValue) { GM.setValue('otm_bearer_token', token); } } catch(_) {} debugLog('Bearer token saved'); } catch (error) { debugLog('Error saving bearer token:', error); } } // Function to get bearer token from GM or localStorage (awaits GM when needed) async function getSavedBearerTokenAsync() { try { let token = null; try { token = sessionStorage.getItem('otm_bearer_token'); } catch(_) { token = null; } if (!token) { try { token = localStorage.getItem('otm_bearer_token'); } catch(_) { token = null; } } if (!token && typeof GM !== 'undefined' && GM.getValue) { try { token = await GM.getValue('otm_bearer_token', ''); if (token) { try { sessionStorage.setItem('otm_bearer_token', token); } catch(_) {} try { localStorage.setItem('otm_bearer_token', token); } catch(_) {} } } catch(_) { token = null; } } if (token) { // Guard against polluted storage values like 'undefined' or too short strings if (!isLikelyValidToken(token)) { debugLog('Found invalid token in storage; cleaning up'); try { sessionStorage.removeItem('otm_bearer_token'); } catch(_) {} try { localStorage.removeItem('otm_bearer_token'); } catch(_) {} try { if (typeof GM !== 'undefined' && GM.deleteValue) { GM.deleteValue('otm_bearer_token'); } } catch(_) {} return null; } debugLog('Found saved bearer token (async)'); return token; } } catch (error) { debugLog('Error getting saved bearer token (async):', error); } return null; } // Function to send message to parent using GM storage function sendMessageToParent(type, data) { debugLog('Sending message to parent via GM storage:', type, data); try { const key = `otm_${type}`; const value = JSON.stringify({ data: { ...(data || {}) }, timestamp: Date.now(), tabId: Math.random().toString(36).substr(2, 9) }); if (typeof GM !== 'undefined' && GM.setValue) { GM.setValue(key, value); debugLog(`Stored ${key} in GM storage`); } else { debugLog('GM.setValue not available, falling back to localStorage'); localStorage.setItem(key, value); } } catch (error) { debugLog('Error storing message:', error); } } // Function to close this tab - always self-close like ?nln flow, with robust fallbacks function closeTab() { debugLog('Closing OTM tab (self + fallback signal)'); // Signal parent as a fallback in case self-close is blocked try { sendMessageToParent('close_tab', { reason: 'self_close_fallback' }); } catch(_) {} // Try TM API if available try { if (typeof GM !== 'undefined' && GM.closeTab) { GM.closeTab(); } } catch(_) {} // Try window.close twice with small delays try { window.close(); } catch (_) { /* ignore */ } setTimeout(() => { try { if (typeof GM !== 'undefined' && GM.closeTab) { GM.closeTab(); } } catch(_) {} try { window.close(); } catch(_) {} }, 300); setTimeout(() => { try { if (typeof GM !== 'undefined' && GM.closeTab) { GM.closeTab(); } } catch(_) {} try { window.close(); } catch(_) {} }, 900); } // Function to test if bearer token is still valid async function testTokenValidity(token) { try { debugLog('Testing token validity...'); // Use a future date for testing (next week) const testDate = new Date(); testDate.setDate(testDate.getDate() + 7); // 7 days from now const isoDate = testDate.toISOString().replace('T00:00:00.000Z', 'T17:00:00.000Z'); debugLog('Test date for token validation:', isoDate); const response = await fetch(`https://otm.tahospital.vn/api/booking?date=${isoDate}`, { headers: { "accept": "application/json, text/plain, */*", "accept-language": "en-US,en;q=0.9,vi;q=0.8", "authorization": `Bearer ${token}`, "if-none-match": "W/\"3de9d-aNgxHg6vKhdB2PNct3jxHFKkaaU\"", "logintype": "2", "priority": "u=1, i", "sec-ch-ua": "\"Not;A=Brand\";v=\"99\", \"Microsoft Edge\";v=\"139\", \"Chromium\";v=\"139\"", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "\"Windows\"", "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "siteid": "1" }, method: "GET", mode: "cors", credentials: "include" }); debugLog('Token validation response status:', response.status); debugLog('Token validation response ok:', response.ok); if (!response.ok) { debugLog('Token validation failed - response not ok'); return false; } // Try to parse the response const data = await response.json(); debugLog('Token validation response data:', data); return true; } catch (error) { debugLog('Token validity test failed with error:', error); debugLog('Error details:', error.message); return false; } } // Check if we should run automation - read from URL parameters const urlParams = new URLSearchParams(window.location.search); const otmFetchParam = urlParams.get('otm-fetch'); const otmFetchUsers = urlParams.has('otm-fetch-users') || urlParams.get('otm-fetch-users') === '1' || urlParams.get('otm-fetch') === 'users'; // Date range for fetching (filled from URL or defaulted later) let fromDate = null; let toDate = null; // Correlation id for messages back to parent let requestId = null; console.log('[OTM Debug] URL params check - otmFetchParam:', !!otmFetchParam); // Always check existing token first console.log('[OTM Debug] Will check existing token first'); debugLog('Checking for existing token...'); // Schedule immediately (next tick) to start as soon as possible setTimeout(() => { console.log('[OTM Debug] Calling checkExistingToken ASAP'); checkExistingToken(); }, 0); // Function to check existing token and start appropriate flow async function checkExistingToken() { let savedToken = await getSavedBearerTokenAsync(); console.log('[OTM Debug] Checking existing token...'); if (savedToken) { console.log('[OTM Debug] Found saved token, using it immediately'); sendMessageToParent('progress', { step: 'token_found', message: 'Đã có token OTM, bắt đầu lấy dữ liệu...' }); // Ensure global bearerToken is set so fetchSurgeryData can use it immediately try { bearerToken = savedToken; } catch(_) {} // Skip pre-validation to save time; fetch will detect 401/403 and fallback if (otmFetchUsers) { try { await fetchOTMUsers(); return; } catch (error) { console.error('Failed to fetch OTM users with saved token:', error); sendMessageToParent('error', { message: 'Lỗi khi lấy danh sách OTM: ' + error.message }); closeTab(); return; } } else if (otmFetchParam) { try { const data = JSON.parse(decodeURIComponent(otmFetchParam)); fromDate = data.fromDate; toDate = data.toDate; const preferToken = !!data.preferToken; console.log('Starting direct API fetch for dates:', fromDate, 'to', toDate); if (preferToken) { // With preferToken, strictly avoid automation when token is present await fetchSurgeryData(fromDate, toDate); return; } await fetchSurgeryData(fromDate, toDate); return; } catch (error) { console.error('Failed to parse OTM fetch data from URL:', error); sendMessageToParent('error', { message: 'Lỗi khi phân tích dữ liệu URL: ' + error.message }); closeTab(); return; } } else { // Default to today const today = new Date(); const yyyy = today.getFullYear(); const mm = String(today.getMonth() + 1).padStart(2, '0'); const dd = String(today.getDate()).padStart(2, '0'); fromDate = `${yyyy}-${mm}-${dd}`; toDate = fromDate; console.log('[OTM Debug] No URL param; defaulting to today and fetching:', fromDate); await fetchSurgeryData(fromDate, toDate); return; } } else { console.log('[OTM Debug] No saved token found'); debugLog('No saved token found, starting automation'); // Grace period: retry a few times to read GM/local storage in case it's not hydrated yet let tries = 0; while (!savedToken && tries < 5) { await new Promise(r => setTimeout(r, 250)); tries++; savedToken = await getSavedBearerTokenAsync(); } if (savedToken) { try { bearerToken = savedToken; } catch(_) {} sendMessageToParent('progress', { step: 'token_found', message: 'Đã có token OTM, bắt đầu lấy dữ liệu...' }); if (otmFetchUsers) { await fetchOTMUsers(); return; } if (otmFetchParam) { try { const data = JSON.parse(decodeURIComponent(otmFetchParam)); fromDate = data.fromDate; toDate = data.toDate; await fetchSurgeryData(fromDate, toDate); return; } catch (e) { sendMessageToParent('error', { message: 'Lỗi khi phân tích dữ liệu URL: ' + e.message }); closeTab(); return; } } const today = new Date(); const yyyy = today.getFullYear(); const mm = String(today.getMonth() + 1).padStart(2, '0'); const dd = String(today.getDate()).padStart(2, '0'); fromDate = `${yyyy}-${mm}-${dd}`; toDate = fromDate; await fetchSurgeryData(fromDate, toDate); return; } sendMessageToParent('progress', { step: 'no_token', message: 'Không tìm thấy token OTM đã lưu, bắt đầu tự động hóa...' }); } // Start automation to get new token (either invalid token or no token) if (otmFetchParam) { // Parse the fetch parameters try { const data = JSON.parse(decodeURIComponent(otmFetchParam)); fromDate = data.fromDate; toDate = data.toDate; console.log('Starting OTM automation for dates:', fromDate, 'to', toDate); } catch (error) { console.error('Failed to parse OTM fetch data from URL:', error); sendMessageToParent('error', { message: 'Lỗi khi phân tích dữ liệu URL: ' + error.message }); closeTab(); return; } } startAutomation(); } // Intercept fetch to capture Bearer token let bearerToken = null; // Will be set by checkExistingToken or interceptors const originalFetch = window.fetch; window.fetch = function(...args) { const [url, options] = args; if (options && options.headers) { // Check for Authorization header const getHeaderVal = (h) => { try { if (!h) return null; // Headers object vs plain object if (typeof h.get === 'function') { return h.get('Authorization') || h.get('authorization') || null; } return h.Authorization || h.authorization || null; } catch { return null; } }; const authHeader = getHeaderVal(options.headers); if (authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) { const newToken = authHeader.substring(7); if (isLikelyValidToken(newToken) && newToken !== bearerToken) { bearerToken = newToken; saveBearerToken(bearerToken); debugLog('Captured Bearer token from fetch headers'); } } } return originalFetch.apply(this, args); }; // Also intercept XMLHttpRequest for token capture const originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url, ...args) { this.addEventListener('loadstart', function() { try { const authHeader = this._headers && (this._headers.Authorization || this._headers.authorization); if (authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) { const newToken = authHeader.substring(7); if (isLikelyValidToken(newToken) && newToken !== bearerToken) { bearerToken = newToken; saveBearerToken(bearerToken); debugLog('Captured Bearer token from XMLHttpRequest'); } } } catch(_) {} }); return originalOpen.apply(this, [method, url, ...args]); }; const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function(header, value) { if (!this._headers) this._headers = {}; this._headers[header] = value; try { if ((header === 'Authorization' || header === 'authorization') && typeof value === 'string' && value.startsWith('Bearer ')) { const newToken = value.substring(7); if (isLikelyValidToken(newToken) && newToken !== bearerToken) { bearerToken = newToken; saveBearerToken(bearerToken); debugLog('Captured Bearer token from XMLHttpRequest setRequestHeader'); } } } catch(_) {} return originalSetRequestHeader.apply(this, [header, value]); }; // Utility functions function findElementByText(tagName, textContent) { const elements = document.querySelectorAll(tagName); for (const element of elements) { const elementText = element.textContent.trim(); // Try exact match first if (elementText === textContent.trim()) { return element; } // Try case-insensitive match if (elementText.toLowerCase() === textContent.trim().toLowerCase()) { return element; } // Try partial match if (elementText.includes(textContent.trim())) { return element; } // Try partial case-insensitive match if (elementText.toLowerCase().includes(textContent.trim().toLowerCase())) { return element; } } return null; } function triggerMouseEvent(element, eventType) { const event = new MouseEvent(eventType, { bubbles: true, cancelable: true }); element.dispatchEvent(event); } function waitForElement(tagName, textContent, timeout = 10000) { return new Promise((resolve) => { const startTime = Date.now(); const interval = setInterval(() => { const element = findElementByText(tagName, textContent); if (element) { clearInterval(interval); resolve(element); } else if (Date.now() - startTime > timeout) { clearInterval(interval); resolve(null); } }, 500); }); } // Transform raw surgery records into a lean structure required by the main UI function filterSurgeryData(records) { if (!Array.isArray(records)) return []; const result = []; for (const r of records) { const item = { customer: { fullname: r?.customer?.fullname ?? null, pid: r?.customer?.code ?? null, dob: r?.customer?.dob ?? null, }, diagnose: r?.diagnose ?? null, surgerymethod: r?.surgerymethod ?? null, start: r?.start ?? null, end: r?.end ?? null, // Optional treatment info khoaLuuTri: r?.khoaLuuTri ?? null, khoaDieuTri: r?.khoaDieuTri ?? null, phongDieuTri: r?.phongDieuTri ?? null, giuongDieuTri: r?.giuongDieuTri ?? null, // Operating room name only operating_room: r?.room?.name ?? null, status: r?.status ?? null, // Surgeons userexec: Array.isArray(r?.userexec) ? r.userexec.map(u => ({ fullname: u?.fullname ?? null, taid: u?.taid ?? null })) : [], userassistant: Array.isArray(r?.userassistant) ? r.userassistant.map(u => ({ fullname: u?.fullname ?? null, taid: u?.taid ?? null })) : [], }; result.push(item); } return result; } // Main automation function async function startAutomation() { try { debugLog('=== STARTING OTM AUTOMATION ==='); sendMessageToParent('progress', { step: 'start', message: 'Bắt đầu tự động hóa OTM...' }); // Wait for page to load (reduced) await new Promise(resolve => setTimeout(resolve, 500)); sendMessageToParent('progress', { step: 'page_loaded', message: 'Trang OTM đã tải xong' }); // Step 1: Find and hover over "Quản lý Phẫu thuật" debugLog('Looking for "Quản lý Phẫu thuật" menu...'); sendMessageToParent('progress', { step: 'finding_menu', message: 'Đang tìm menu "Quản lý Phẫu thuật"...' }); const quanLyPhauThuatElement = await waitForElement('p', 'Quản lý Phẫu thuật', 8000); if (!quanLyPhauThuatElement) { debugLog('Không tìm thấy phần tử "Quản lý Phẫu thuật". Có thể account không có quyền truy cập.'); sendMessageToParent('error', { message: 'Không tìm thấy menu "Quản lý Phẫu thuật". Có thể tài khoản không có quyền truy cập.' }); closeTab(); return; } debugLog('Tìm thấy "Quản lý Phẫu thuật", kích hoạt mouseover...'); sendMessageToParent('progress', { step: 'menu_found', message: 'Đã tìm thấy menu, đang mở submenu...' }); // Step 2: Trigger mouseover to show submenu triggerMouseEvent(quanLyPhauThuatElement, 'mouseover'); // Wait a bit for submenu to appear (reduced from 1000ms) await new Promise(resolve => setTimeout(resolve, 500)); // Step 3: Try different selectors for submenu (reduced timeouts) debugLog('Tìm submenu "Đặt hẹn Lịch mổ"...'); sendMessageToParent('progress', { step: 'finding_submenu', message: 'Đang tìm submenu "Đặt hẹn Lịch mổ"...' }); let datHenLichMoElement = await waitForElement('h6', 'Đặt hẹn Lịch mổ', 800); if (!datHenLichMoElement) { datHenLichMoElement = await waitForElement('p', 'Đặt hẹn Lịch mổ', 800); } if (!datHenLichMoElement) { // Try partial text match datHenLichMoElement = await waitForElement('h6', 'Đặt hẹn', 900); } if (!datHenLichMoElement) { datHenLichMoElement = await waitForElement('p', 'Đặt hẹn', 900); } if (!datHenLichMoElement) { // Last resort: search all clickable elements const allClickable = document.querySelectorAll('button, a, [role="button"], [onclick]'); for (const el of allClickable) { if (el.textContent && el.textContent.toLowerCase().includes('đặt hẹn')) { datHenLichMoElement = el; debugLog('Found via clickable elements search:', el.textContent.trim()); break; } } } // Debug: Log all possible elements console.log('Debug: Tất cả elements h6:', Array.from(document.querySelectorAll('h6')).map(el => el.textContent.trim())); console.log('Debug: Tất cả elements p:', Array.from(document.querySelectorAll('p')).map(el => el.textContent.trim())); if (datHenLichMoElement) { debugLog('Tìm thấy "Đặt hẹn Lịch mổ", click...'); sendMessageToParent('progress', { step: 'submenu_found', message: 'Đã tìm thấy submenu, đang chuyển trang...' }); datHenLichMoElement.click(); // Wait for the surgery scheduling page to load await new Promise(resolve => setTimeout(resolve, 1000)); sendMessageToParent('progress', { step: 'page_ready', message: 'Trang đặt lịch đã sẵn sàng' }); // Now we can fetch the surgery data if date range is available if (otmFetchUsers) { await fetchOTMUsers(); } else if (fromDate && toDate) { await fetchSurgeryData(fromDate, toDate); } else { debugLog('No date range provided; token should be captured by now. Closing tab.'); sendMessageToParent('progress', { step: 'token_ready', message: 'Token đã sẵn sàng' }); closeTab(); } } else { debugLog('Không tìm thấy submenu "Đặt hẹn Lịch mổ"'); debugLog('Debug: Current URL:', window.location.href); debugLog('Debug: Page title:', document.title); sendMessageToParent('error', { message: 'Không tìm thấy submenu "Đặt hẹn Lịch mổ"' }); closeTab(); } } catch (error) { console.error('Lỗi trong quá trình automation:', error); sendMessageToParent('error', { message: 'Lỗi trong quá trình tự động hóa: ' + error.message }); closeTab(); } } // Fetch OTM users list and return to parent async function fetchOTMUsers() { try { sendMessageToParent('progress', { step: 'token_wait', message: 'Đang chờ token xác thực...' }); let attempts = 0; while (!bearerToken && attempts < 10) { await new Promise(r => setTimeout(r, 1000)); attempts++; } if (!bearerToken) { sendMessageToParent('error', { message: 'Không thể lấy token xác thực sau 10 lần thử' }); closeTab(); return; } const query = 'ishsoft=null&page=1&limit=10000'; const url = `https://otm.tahospital.vn/api/user?${query}&_=${Date.now()}`; debugLog('Fetching OTM users:', url); debugLog('Using Bearer token (prefix):', (bearerToken || '').slice(0, 12) + '...'); const res = await fetch(url, { headers: { 'accept': 'application/json, text/plain, */*', 'accept-language': 'en-US,en;q=0.9,vi;q=0.8', 'authorization': `Bearer ${bearerToken}`, 'logintype': '2', 'priority': 'u=1, i', 'sec-ch-ua': '"Not;A=Brand";v="99", "Microsoft Edge";v="139", "Chromium";v="139"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"Windows"', 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors', 'sec-fetch-site': 'same-origin', 'siteid': '1' }, referrer: 'https://otm.tahospital.vn/surgery/booking', body: null, method: 'GET', mode: 'cors', cache: 'no-store', credentials: 'include' }); debugLog('Users fetch status:', res.status, 'ok:', res.ok); debugLog('Users fetch headers etag:', res.headers && res.headers.get ? res.headers.get('etag') : undefined); let effectiveRes = res; if (res.status === 304) { // Retry once without caches using a cache-busting param debugLog('Received 304 for OTM users. Retrying with cache-busting...'); const bustUrl = `${url}&_=${Date.now()}`; const retry = await fetch(bustUrl, { headers: { 'accept': 'application/json, text/plain, */*', 'accept-language': 'en-US,en;q=0.9,vi;q=0.8', 'authorization': `Bearer ${bearerToken}`, 'logintype': '2', 'siteid': '1' }, referrer: 'https://otm.tahospital.vn/surgery/booking', body: null, method: 'GET', mode: 'cors', cache: 'no-store', credentials: 'include' }); effectiveRes = retry; debugLog('Retry users fetch status:', effectiveRes.status, 'ok:', effectiveRes.ok); } if (!effectiveRes.ok) { if (effectiveRes.status === 401 || effectiveRes.status === 403) { sendMessageToParent('progress', { step: 'token_invalid', message: 'Token hết hạn, chuyển sang tự động hóa để lấy token mới...' }); startAutomation(); return; } throw new Error('HTTP ' + effectiveRes.status); } let json; try { json = await effectiveRes.json(); } catch (parseErr) { debugLog('Users JSON parse error:', parseErr); try { const txt = await effectiveRes.clone().text(); debugLog('Users raw text (first 300 chars):', (txt || '').slice(0, 300)); } catch {} throw parseErr; } const keys = json && typeof json === 'object' ? Object.keys(json) : []; debugLog('Users response type:', Array.isArray(json) ? 'array' : typeof json, 'keys:', keys); function pickArrayPayload(obj) { if (Array.isArray(obj)) return { arr: obj, via: 'root' }; if (!obj || typeof obj !== 'object') return { arr: [], via: 'none' }; const candidates = ['data', 'items', 'result', 'rows', 'content', 'users', 'records', 'list']; for (const k of candidates) { const v = obj[k]; if (Array.isArray(v)) return { arr: v, via: k }; if (v && typeof v === 'object') { for (const kk of candidates) { const v2 = v[kk]; if (Array.isArray(v2)) return { arr: v2, via: `${k}.${kk}` }; } } } return { arr: [], via: 'not_found' }; } const { arr: usersArray, via } = pickArrayPayload(json); debugLog('Users array source:', via, 'length:', usersArray.length || 0); let users = (usersArray || []).map(u => ({ id: u.id ?? u.taid ?? u.userid ?? u.userId ?? null, fullname: u.fullname || u.fullName || u.name || '' })); if ((users?.length || 0) === 0) { // Extra diagnostics for empty payloads debugLog('Empty users after parse. Sample payload snapshot:', JSON.stringify(json).slice(0, 400)); sendMessageToParent('progress', { step: 'users_empty', via, keys, hint: 'Parsed 0 users from payload', url }); // Optional fallback: drop ishsoft param if enabled if (localStorage.getItem('dr_otm_users_alt') === 'drop_ishsoft') { const altUrl = 'https://otm.tahospital.vn/api/user?page=1&limit=10000&_=' + Date.now(); debugLog('Fallback fetch without ishsoft:', altUrl); sendMessageToParent('progress', { step: 'users_alt_fetch', message: 'Thử lại không có ishsoft', altUrl }); const altRes = await fetch(altUrl, { headers: { 'accept': 'application/json, text/plain, */*', 'accept-language': 'en-US,en;q=0.9,vi;q=0.8', 'authorization': `Bearer ${bearerToken}`, 'logintype': '2', 'priority': 'u=1, i', 'sec-ch-ua': '"Not;A=Brand";v="99", "Microsoft Edge";v="139", "Chromium";v="139"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"Windows"', 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors', 'sec-fetch-site': 'same-origin', 'siteid': '1' }, referrer: 'https://otm.tahospital.vn/surgery/booking', body: null, method: 'GET', mode: 'cors', cache: 'no-store', credentials: 'include' }); if (altRes.ok) { let altJson; try { altJson = await altRes.json(); } catch {} const altKeys = altJson && typeof altJson === 'object' ? Object.keys(altJson) : []; const pickAlt = pickArrayPayload(altJson); debugLog('Alt users array source:', pickAlt.via, 'length:', (pickAlt.arr || []).length || 0, 'keys:', altKeys); users = (pickAlt.arr || []).map(u => ({ id: u.id ?? u.taid ?? u.userid ?? u.userId ?? null, fullname: u.fullname || u.fullName || u.name || '' })); sendMessageToParent('progress', { step: 'users_alt_parsed', count: users.length, via: pickAlt.via }); } else { debugLog('Alt users fetch failed:', altRes.status); sendMessageToParent('progress', { step: 'users_alt_failed', status: altRes.status }); } } } // Emit a parse summary to parent for debugging const sample = (users || []).slice(0, 3).map(u => u.fullname); sendMessageToParent('progress', { step: 'users_parsed', count: users.length, via, sample }); sendMessageToParent('success', { otmUsers: users, count: users.length, summary: `Đã tải ${users.length} người dùng từ OTM` }); closeTab(); } catch (e) { sendMessageToParent('error', { message: 'Lỗi khi lấy DS người dùng OTM: ' + (e.message || e) }); closeTab(); } } // Function to fetch surgery data for a date range async function fetchSurgeryData(fromDate, toDate) { try { debugLog('=== STARTING SURGERY DATA FETCH ==='); // Keep original requested range for reporting const requestedFrom = fromDate; const requestedTo = toDate; debugLog('Requested range:', requestedFrom, 'to', requestedTo); // Send progress update to parent sendMessageToParent('progress', { step: 'token_wait', message: 'Đang chờ token xác thực...' }); // Wait for token if not available yet let attempts = 0; while (!bearerToken && attempts < 10) { debugLog(`Waiting for Bearer token... (attempt ${attempts + 1}/10)`); await new Promise(resolve => setTimeout(resolve, 1000)); attempts++; } if (!bearerToken) { debugLog('No Bearer token captured after 10 attempts'); sendMessageToParent('error', { message: 'Không thể lấy token xác thực sau 10 lần thử' }); closeTab(); return; } debugLog('Bearer token available:', bearerToken.substring(0, 20) + '...'); sendMessageToParent('progress', { step: 'token_ready', message: 'Token đã sẵn sàng, đang lấy dữ liệu...' }); // Generate array of dates from requestedFrom to requestedTo (inclusive) function toDateOnly(dateStr) { // Always treat as date-only without timezone shifting const [y, m, d] = dateStr.split('-').map(n => parseInt(n, 10)); return new Date(Date.UTC(y, (m - 1), d)); // UTC midnight for stability } const dates = []; const startDate = toDateOnly(requestedFrom); const endDate = toDateOnly(requestedTo); const cur = new Date(startDate.getTime()); while (cur.getTime() <= endDate.getTime()) { const y = cur.getUTCFullYear(); const m = String(cur.getUTCMonth() + 1).padStart(2, '0'); const d = String(cur.getUTCDate()).padStart(2, '0'); dates.push(`${y}-${m}-${d}`); cur.setUTCDate(cur.getUTCDate() + 1); } debugLog('Dates to fetch:', dates); const allSurgeryData = []; let totalSurgeries = 0; const getConcurrencyLimit = () => { const raw = localStorage.getItem('dr_otm_concurrency'); const n = parseInt(raw ?? '3', 10); return isNaN(n) ? 3 : Math.min(Math.max(n, 1), 6); }; async function fetchDateData(currentDate) { sendMessageToParent('progress', { step: 'api_call', message: `Đang gọi API cho ngày ${currentDate}...` }); // Convert local Vietnam midnight (UTC+07:00) to exact Z time for API // e.g., '2025-09-07T00:00:00+07:00' -> '2025-09-06T17:00:00.000Z' const isoDate = new Date(`${currentDate}T00:00:00+07:00`).toISOString(); debugLog(`Fetching data for date: ${currentDate} (ISO: ${isoDate})`); sendMessageToParent('progress', { step: 'api_call', message: `GET /api/booking?date=${isoDate}`, currentDate, isoDate }); let response = await fetch(`https://otm.tahospital.vn/api/booking?date=${isoDate}`, { headers: { "accept": "application/json, text/plain, */*", "accept-language": "en-US,en;q=0.9,vi;q=0.8", "authorization": `Bearer ${bearerToken}`, "logintype": "2", "priority": "u=1, i", "sec-ch-ua": "\"Not;A=Brand\";v=\"99\", \"Microsoft Edge\";v=\"139\", \"Chromium\";v=\"139\"", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "\"Windows\"", "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "siteid": "1" }, referrer: "https://otm.tahospital.vn/surgery/booking", body: null, method: "GET", mode: "cors", cache: "no-store", credentials: "include" }); if (response.status === 304) { debugLog(`Received 304 for ${currentDate}. Retrying with cache-busting...`); const retryUrl = `https://otm.tahospital.vn/api/booking?date=${isoDate}&_=${Date.now()}`; response = await fetch(retryUrl, { headers: { "accept": "application/json, text/plain, */*", "authorization": `Bearer ${bearerToken}`, "logintype": "2", "siteid": "1" }, referrer: "https://otm.tahospital.vn/surgery/booking", body: null, method: "GET", mode: "cors", cache: "no-store", credentials: "include" }); } if (!response.ok) { debugLog(`HTTP error for ${currentDate}: ${response.status}`); if (response.status === 401 || response.status === 403) { const err = new Error('Unauthorized'); err.__unauthorized = true; throw err; } return { surgeriesWithDate: [], count: 0 }; } let data; try { data = await response.json(); } catch (parseErr) { debugLog('Booking JSON parse error:', parseErr); try { const raw = await response.clone().text(); debugLog('Booking raw (first 300):', (raw||'').slice(0,300)); } catch {} return { surgeriesWithDate: [], count: 0 }; } debugLog(`Surgery data received for ${currentDate}:`, data); if (!Array.isArray(data) || data.length === 0) return { surgeriesWithDate: [], count: 0 }; const surgeriesWithDate = data.map(surgery => ({ ...surgery, fetchDate: currentDate })); debugLog(`Full surgery data for ${currentDate}:`, data); return { surgeriesWithDate, count: data.length }; } const concurrency = getConcurrencyLimit(); let unauthorizedDetected = false; for (let i = 0; i < dates.length; i += concurrency) { const batch = dates.slice(i, i + concurrency); const results = await Promise.allSettled(batch.map(d => fetchDateData(d))); for (const res of results) { if (res.status === 'rejected') { if (res.reason && res.reason.__unauthorized) { unauthorizedDetected = true; break; } else { debugLog('Batch fetch error:', res.reason); } } else if (res.value) { const { surgeriesWithDate, count } = res.value; if (count > 0) { totalSurgeries += count; allSurgeryData.push(...surgeriesWithDate); } } } if (unauthorizedDetected) { sendMessageToParent('progress', { step: 'token_invalid', message: 'Token hết hạn, chuyển sang tự động hóa để lấy token mới...' }); startAutomation(); return; } // Small delay between batches to avoid rate limiting if (i + concurrency < dates.length) { await new Promise(resolve => setTimeout(resolve, 200)); } } sendMessageToParent('progress', { step: 'data_received', message: 'Đã nhận dữ liệu từ API', days: dates.length, totalCandidate: allSurgeryData.length }); // Send success data to parent with all collected data (raw + filtered) const filteredSurgeryData = filterSurgeryData(allSurgeryData); if (filteredSurgeryData.length > 0) { const message = `Tìm thấy tổng cộng ${totalSurgeries} ca mổ từ ${requestedFrom} đến ${requestedTo}:\n\n` + allSurgeryData.map((item, index) => `${index + 1}. ${item.customer?.fullname || 'N/A'} - ${item.surgerymethod || 'N/A'} (${item.fetchDate})` ).join('\n'); sendMessageToParent('success', { surgeryData: filteredSurgeryData, surgeryDataRaw: allSurgeryData, count: filteredSurgeryData.length, dateRange: { from: requestedFrom, to: requestedTo }, summary: message }); debugLog('=== SURGERY DATA FETCH COMPLETED SUCCESSFULLY ==='); debugLog(`Total surgeries found (filtered): ${filteredSurgeryData.length}`); } else { sendMessageToParent('success', { surgeryData: [], surgeryDataRaw: allSurgeryData, count: 0, dateRange: { from: requestedFrom, to: requestedTo }, summary: `Không tìm thấy dữ liệu mổ từ ${requestedFrom} đến ${requestedTo}` }); } // Small delay to let storage events propagate before closing setTimeout(() => { try { closeTab(); } catch(_) {} }, 350); } catch (error) { debugLog('Error fetching surgery data:', error); debugLog('Lỗi khi lấy dữ liệu mổ: ' + error.message); sendMessageToParent('error', { message: 'Lỗi khi lấy dữ liệu mổ: ' + error.message, error: error.toString() }); setTimeout(() => { try { closeTab(); } catch(_) {} }, 350); } } // Start automation when page loads if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { // checkExistingToken() is already called at the top }); } else { // checkExistingToken() is already called at the top } })(); },{}],20:[function(require,module,exports){ (function (global){(function (){ // dashboard.js const Utils = require('../utils'); const { createDirectReportGeneration, addGlobalStyles } = require('./page.dashboard.support'); // Import cài đặt giao diện const BS_CAI_DAT = require('../BS_CAI_DAT_GIAO_DIEN'); // Import refactored modules const PatientService = require('../services/patientService'); const ChecklistService = require('../services/checklistService'); const PatientDataMapper = require('../utils/patientDataMapper'); const ModalManager = require('../components/modalManager'); const LoginHandler = require('../components/loginHandler'); // Import newly refactored components const { createPatientInfoSection } = require('../components/patientInfoSection'); const SidebarSession = require('../components/sidebarSession'); const { createYLenhTags, updatePatientCardTags, hasDischargeTag, updateMedsDoneBadge } = require('../utils/tagUtils'); const { setupPhauThuatHandlers } = require('../components/phauThuatHandlers'); // Import utility functions const { showToast, copyToClipboard } = require('../utils/uiUtils'); const { addSurgeryStatusIcon, formatSurgeryInfo, updatePatientCardPhauThuat } = require('../utils/surgeryUtils'); const { escapeHtml } = require('../utils/htmlUtils'); const DomUpdaters = require('../utils/domUpdaters'); const { createChecklistItemHTML, copyYLenhText, checkCelebrationForCard, checkAllCelebrationAnimations } = require('../utils/checklistUtils'); // Global variable for OTM tabs if (typeof window !== 'undefined') { window.openTabs = window.openTabs || []; } else if (typeof global !== 'undefined') { global.openTabs = global.openTabs || []; } else { this.openTabs = this.openTabs || []; } function showDashboardBenhNhanIfNeeded() { // Use global openTabs variable for OTM tabs if (!window.openTabs) window.openTabs = []; let openTabs = window.openTabs; if (!(/[?&](show=true|nln)($|&)/.test(window.location.search))) return; addGlobalStyles(); // Đảm bảo style chỉ chèn 1 lần // Make utility functions globally available for onclick handlers // Không sử dụng window để tránh lỗi undefined - sử dụng global assignment trực tiếp if (typeof unsafeWindow !== 'undefined') { unsafeWindow.showToast = showToast; unsafeWindow.copyToClipboard = copyToClipboard; unsafeWindow.copyYLenhText = copyYLenhText; unsafeWindow.updatePatientCardPhauThuat = updatePatientCardPhauThuat; unsafeWindow.updatePatientCardHXT = DomUpdaters.updateHXT; unsafeWindow.updatePatientCardCDKT = DomUpdaters.updateCDKT; } else if (typeof this !== 'undefined') { this.showToast = showToast; this.copyToClipboard = copyToClipboard; this.copyYLenhText = copyYLenhText; this.updatePatientCardPhauThuat = updatePatientCardPhauThuat; this.updatePatientCardHXT = DomUpdaters.updateHXT; this.updatePatientCardCDKT = DomUpdaters.updateCDKT; } else { // Fallback - tạo global functions không qua window globalThis.showToast = showToast; globalThis.copyToClipboard = copyToClipboard; globalThis.copyYLenhText = copyYLenhText; globalThis.updatePatientCardPhauThuat = updatePatientCardPhauThuat; globalThis.updatePatientCardHXT = DomUpdaters.updateHXT; globalThis.updatePatientCardCDKT = DomUpdaters.updateCDKT; } // Styles are injected via addGlobalStyles() only const checklistItems = BS_CAI_DAT.checklistItems; const quickYLenhActions = BS_CAI_DAT.quickYLenhActions; function createChecklistSection(patient) { const checklistDiv = document.createElement('div'); // Determine if patient has discharge tag const hasDischarge = hasDischargeTag(patient); const defaultTab = hasDischarge ? 'xuatvien' : 'bomo'; checklistDiv.innerHTML = ` <h3 style="margin-top:0">Checklist</h3> <div class="checklist-tabs" style="display:flex;margin-bottom:16px;border-bottom:2px solid #e0e0e0;"> <button class="tab-btn ${defaultTab === 'bomo' ? 'active' : ''}" data-tab="bomo" style="padding:8px 16px;border:none;background:${defaultTab === 'bomo' ? '#1976d2' : 'transparent'};color:${defaultTab === 'bomo' ? 'white' : '#666'};border-radius:4px 4px 0 0;cursor:pointer;font-weight:${defaultTab === 'bomo' ? 'bold' : 'normal'};">Bộ mổ</button> <button class="tab-btn ${defaultTab === 'xuatvien' ? 'active' : ''}" data-tab="xuatvien" style="padding:8px 16px;border:none;background:${defaultTab === 'xuatvien' ? '#4caf50' : 'transparent'};color:${defaultTab === 'xuatvien' ? 'white' : '#666'};border-radius:4px 4px 0 0;cursor:pointer;margin-left:4px;font-weight:${defaultTab === 'xuatvien' ? 'bold' : 'normal'};">Xuất viện</button> </div> <div class="tab-content"> <div class="tab-pane ${defaultTab === 'bomo' ? 'active' : ''}" data-tab="bomo" style="display:${defaultTab === 'bomo' ? 'block' : 'none'};"> <ul id="checklist-bomo" style="overflow-y:auto;padding-left:0;list-style:none;margin:0 0 16px 0;"></ul> </div> <div class="tab-pane ${defaultTab === 'xuatvien' ? 'active' : ''}" data-tab="xuatvien" style="display:${defaultTab === 'xuatvien' ? 'block' : 'none'};"> <ul id="checklist-xuatvien" style="overflow-y:auto;padding-left:0;list-style:none;margin:0 0 16px 0;"></ul> </div> </div> `; // Setup tab switching setTimeout(() => { const tabBtns = checklistDiv.querySelectorAll('.tab-btn'); const tabPanes = checklistDiv.querySelectorAll('.tab-pane'); tabBtns.forEach(btn => { btn.addEventListener('click', function() { const targetTab = this.getAttribute('data-tab'); // Update buttons tabBtns.forEach(b => { b.classList.remove('active'); b.style.background = 'transparent'; b.style.color = '#666'; b.style.fontWeight = 'normal'; }); this.classList.add('active'); this.style.background = targetTab === 'bomo' ? '#1976d2' : '#4caf50'; this.style.color = 'white'; this.style.fontWeight = 'bold'; // Update panes tabPanes.forEach(pane => { pane.classList.remove('active'); pane.style.display = 'none'; }); const targetPane = checklistDiv.querySelector(`.tab-pane[data-tab="${targetTab}"]`); if (targetPane) { targetPane.classList.add('active'); targetPane.style.display = 'block'; } }); }); }, 10); // Load both checklists const bomoList = checklistDiv.querySelector('#checklist-bomo'); const xuatvienList = checklistDiv.querySelector('#checklist-xuatvien'); if (bomoList) { loadChecklist(patient, bomoList, 'bomo'); } if (xuatvienList) { loadChecklistXuatVien(patient, xuatvienList); } return checklistDiv; } // Helper function to load checklist data async function loadChecklist(patient, checklistUl, checklistType = 'bomo', retryCount = 0) { try { checklistUl.innerHTML = '<li>Đang tải checklist...</li>'; const res = await ChecklistService.loadChecklistData(patient, { forceRefresh: true }); checklistUl.innerHTML = ''; let checklistObj = ChecklistService.findChecklistObject(res); if (!checklistObj) { checklistUl.innerHTML = '<li>Không có dữ liệu</li>'; const created = await ChecklistService.createNewChecklist(patient); if (created) { loadChecklist(patient, checklistUl, checklistType, retryCount + 1); } else { checklistUl.innerHTML = '<li>Lỗi tạo mới checklist phiếu!</li>'; if (retryCount < 1) { setTimeout(() => { const sidebar = document.getElementById('dr-sidebar'); const backdrop = document.getElementById('dr-sidebar-backdrop'); ModalManager.hideModal(sidebar, backdrop); setTimeout(() => { showSidebar(patient); }, 300); }, 500); } } return; } window.checklistObj = checklistObj; // Parse into a fresh object; avoid leaking prior patient's HXT into others const parsedState = ChecklistService.parseChecklistState(checklistObj) || {}; window.checklistState = { ...parsedState }; // Merge standardized OTM surgeries (if any) for this patient into state (append-only) try { const otmLogs = Array.isArray(patient && patient._otmPhauThuatLog) ? patient._otmPhauThuatLog : []; if (otmLogs.length > 0) { if (!Array.isArray(window.checklistState.phauThuatLog)) window.checklistState.phauThuatLog = []; const keyOf = (e) => `${e.date}|${e.time}|${(e.method||'').trim().toLowerCase()}`; const existingKeys = new Set(window.checklistState.phauThuatLog.map(keyOf)); let added = 0; for (const e of otmLogs) { const k = keyOf(e); if (!existingKeys.has(k)) { window.checklistState.phauThuatLog.push({ ...e }); existingKeys.add(k); added++; } } if (added > 0) { const parseDDMMYYYY = (s) => { const [d,m,y] = String(s||'').split('/').map(n=>parseInt(n,10)); return new Date(y||1970,(m||1)-1,d||1); }; const toTs = (e) => { const dt = parseDDMMYYYY(e.date); const [hh,mm] = String(e.time||'00:00').split(':').map(n=>parseInt(n,10)||0); dt.setHours(hh, mm, 0, 0); return dt.getTime(); }; window.checklistState.phauThuatLog.sort((a,b) => toTs(b)-toTs(a)); // Persist silently in background try { ChecklistService.updateChecklistState(window.checklistObj, window.checklistState, { enqueueOnOffline: true, ctxId: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.id), signal: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.signal) }); } catch (_) {} } } } catch (e) { console.warn('OTM merge into checklistState failed', e); } // Load y lệnh log if exists const yLenhLogContainer = document.getElementById('dr-y-lenh-log'); if (yLenhLogContainer && window.checklistState && window.checklistState.yLenhLog) { loadYLenhLogFromState(); } // Load phẫu thuật log if exists const ptLogContainer = document.getElementById('dr-pt-log'); if (ptLogContainer && window.checklistState && window.checklistState.phauThuatLog) { loadPhauThuatLogFromState(); } // Render checklist items for bộ mổ if (checklistType === 'bomo') { renderChecklistItems(checklistUl); } } catch (error) { console.error('Error loading checklist:', error); checklistUl.innerHTML = '<li>Lỗi tải checklist</li>'; } } // Helper function to load checklist xuất viện function loadChecklistXuatVien(patient, checklistUl) { try { checklistUl.innerHTML = '<li>Đang tải checklist xuất viện...</li>'; setTimeout(() => { renderChecklistXuatVien(checklistUl, patient); }, 100); } catch (error) { console.error('Error loading xuất viện checklist:', error); checklistUl.innerHTML = '<li>Lỗi tải checklist xuất viện</li>'; } } // Helper function to render checklist xuất viện function renderChecklistXuatVien(checklistUl, patient) { checklistUl.innerHTML = ''; BS_CAI_DAT.checklistXuatVien.forEach((item, idx) => { const li = document.createElement('li'); li.style = 'margin-bottom:8px;'; if (typeof item === 'string') { // Simple checklist item const id = 'dr-checklist-xv-' + idx; const isChecked = window.checklistState && window.checklistState[`xuatvien_${item}`] || false; li.innerHTML = createChecklistItemHTML(item, id, isChecked, patient); } else if (item.children) { // Parent item with children - Special handling for "Tờ điều trị" if (item.label === 'Tờ điều trị') { // Render as header without checkbox li.innerHTML = ` <div style="margin-bottom:12px;"> <h4 style="margin:0 0 8px 0;color:#1976d2;font-weight:bold;border-bottom:2px solid #e3f2fd;padding-bottom:4px;">📋 ${item.label}</h4> <ul style="margin-left:0;margin-top:8px;list-style:none;padding:0;"> ${item.children.map((child, childIdx) => { const childId = `dr-checklist-xv-child-${idx}-${childIdx}`; const isChildChecked = window.checklistState && window.checklistState[`xuatvien_${child}`] || false; return `<li style="margin-bottom:4px;">${createChecklistItemHTML(child, childId, isChildChecked, patient)}</li>`; }).join('')} </ul> </div> `; } else { // Normal parent item with checkbox const parentId = 'dr-checklist-xv-parent-' + idx; const isParentChecked = window.checklistState && window.checklistState[`xuatvien_${item.label}`] || false; li.innerHTML = ` <div style="margin-bottom:8px;"> <label style="display:flex;align-items:center;gap:8px;font-weight:bold;"> <input type="checkbox" id="${parentId}" ${isParentChecked ? 'checked' : ''}>${item.label} </label> <ul style="margin-left:24px;margin-top:8px;list-style:none;padding:0;"> ${item.children.map((child, childIdx) => { const childId = `dr-checklist-xv-child-${idx}-${childIdx}`; const isChildChecked = window.checklistState && window.checklistState[`xuatvien_${child}`] || false; return `<li style="margin-bottom:4px;">${createChecklistItemHTML(child, childId, isChildChecked, patient)}</li>`; }).join('')} </ul> </div> `; } } checklistUl.appendChild(li); }); // Setup checkbox change handlers for xuất viện setTimeout(() => { checklistUl.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.addEventListener('change', async function () { const label = this.parentNode.textContent.trim(); const key = `xuatvien_${label}`; if (!window.checklistState) { window.checklistState = {}; } window.checklistState[key] = this.checked; const res = await ChecklistService.updateChecklistState(window.checklistObj, window.checklistState, { enqueueOnOffline: true, ctxId: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.id), signal: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.signal) }); if (!res || (!res.ok && !res.queued)) { console.error('Lưu checklist xuất viện thất bại!'); } }); }); }, 10); // Make function available for reuse window.renderChecklistXuatVien = renderChecklistXuatVien; } // Helper function to load y lệnh log from state function loadYLenhLogFromState() { const logContainer = document.getElementById('dr-y-lenh-log'); if (!logContainer) return; function renderYLenhLog(yLenhArray) { if (!Array.isArray(yLenhArray) || yLenhArray.length === 0) { logContainer.innerHTML = '<div style="color:#888;font-style:italic;">Chưa có y lệnh nào...</div>'; return; } logContainer.innerHTML = yLenhArray.map((entry, index) => ` <div style="margin-bottom:8px;padding:8px 40px 8px 8px;background:#fff;border-radius:4px;border-left:3px solid #1976d2;position:relative;word-break: break-word; overflow-wrap: anywhere;"> <button class="remove-y-lenh-btn" data-index="${index}" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:#d32f2f;color:#fff;border:none;border-radius:3px;padding:2px 6px;font-size:0.8em;cursor:pointer;">Xóa</button> <div style="font-size:0.9em;color:#666;margin-bottom:4px;">${entry.timestamp}</div> <div style="font-weight:bold;color:#333;">${entry.content}</div> </div> `).join(''); // Add event listeners for remove buttons setTimeout(() => { logContainer.querySelectorAll('.remove-y-lenh-btn').forEach(btn => { btn.addEventListener('click', function() { const index = parseInt(this.getAttribute('data-index')); if (window.currentRemoveYLenh) { window.currentRemoveYLenh(index); } else { // Fallback removal function if (window.checklistState.yLenhLog && Array.isArray(window.checklistState.yLenhLog)) { window.checklistState.yLenhLog.splice(index, 1); // Re-render after removal renderYLenhLog(window.checklistState.yLenhLog); // Save to server if (window.checklistObj) { ChecklistService.updateChecklistState(window.checklistObj, window.checklistState, { enqueueOnOffline: true, ctxId: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.id), signal: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.signal) }); } } } }); }); }, 10); } if (window.checklistState && window.checklistState.yLenhLog) { renderYLenhLog(window.checklistState.yLenhLog); } } // Helper function to load phẫu thuật log from state function loadPhauThuatLogFromState() { const logContainer = document.getElementById('dr-pt-log'); if (!logContainer) return; // Use the current render function if available if (window.currentRenderPhauThuatLog && window.checklistState && window.checklistState.phauThuatLog) { window.currentRenderPhauThuatLog(window.checklistState.phauThuatLog); return; } // Fallback render function function renderPhauThuatLog(phauThuatArray) { if (!Array.isArray(phauThuatArray) || phauThuatArray.length === 0) { logContainer.innerHTML = '<div style="color:#888;font-style:italic;">Chưa có phẫu thuật nào...</div>'; return; } logContainer.innerHTML = phauThuatArray.map((entry, index) => ` <div class="pt-entry-clickable" data-index="${index}" style="margin-bottom:8px;padding:8px 40px 8px 8px;background:#fff;border-radius:4px;border-left:3px solid #4caf50;position:relative;cursor:pointer;transition:background-color 0.2s;" onmouseover="this.style.backgroundColor='#f5f5f5'" onmouseout="this.style.backgroundColor='#fff'"> <button class="remove-pt-btn" data-index="${index}" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:#d32f2f;color:#fff;border:none;border-radius:3px;padding:2px 6px;font-size:0.8em;cursor:pointer;z-index:1;">Xóa</button> <div style="font-size:0.9em;color:#666;margin-bottom:4px;"><strong>Ngày PT:</strong> ${entry.date} ${entry.time}</div> <div style="font-weight:bold;color:#333;margin-bottom:2px;"><strong>PPPT:</strong> ${entry.method}</div> <div style="font-size:0.85em;color:#555;"><strong>BS:</strong> ${entry.doctors}</div> </div> `).join(''); // Add event listeners for remove buttons setTimeout(() => { logContainer.querySelectorAll('.remove-pt-btn').forEach(btn => { btn.addEventListener('click', function(e) { e.stopPropagation(); // Prevent triggering edit popup const index = parseInt(this.getAttribute('data-index')); if (window.currentRemovePhauThuat) { window.currentRemovePhauThuat(index); } else { // Fallback removal function if (window.checklistState.phauThuatLog && Array.isArray(window.checklistState.phauThuatLog)) { window.checklistState.phauThuatLog.splice(index, 1); // Re-render after removal renderPhauThuatLog(window.checklistState.phauThuatLog); // Save to server if (window.checklistObj) { ChecklistService.updateChecklistState(window.checklistObj, window.checklistState, { enqueueOnOffline: true, ctxId: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.id), signal: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.signal) }); } } } }); }); // Add event listeners for edit functionality logContainer.querySelectorAll('.pt-entry-clickable').forEach(entry => { entry.addEventListener('click', function(e) { // Don't trigger if clicking the remove button if (e.target.classList.contains('remove-pt-btn')) return; const index = parseInt(this.getAttribute('data-index')); if (window.currentEditPhauThuat) { window.currentEditPhauThuat(index); } else { console.warn('Edit function not available'); } }); }); }, 10); } if (window.checklistState && window.checklistState.phauThuatLog) { renderPhauThuatLog(window.checklistState.phauThuatLog); } } // Helper function to render checklist items with HSBA badges and sync note function renderChecklistItems(checklistUl) { checklistUl.innerHTML = ''; const hsbaSynced = (window.checklistState && window.checklistState.hsbaSynced) || {}; const lastSyncAt = hsbaSynced.__lastSyncAt || null; checklistItems.forEach((item, idx) => { const li = document.createElement('li'); // No margin/padding; keep optional background and radius only let liStyle = 'border-radius:6px;'; const id = 'dr-checklist-' + idx; const isChecked = !!(window.checklistState && window.checklistState[item]); const auto = hsbaSynced[item] && hsbaSynced[item].matched === true; const badge = auto ? `<span class="dr-hsba-badge" title="Đã có trong HSBA" style="color:#16a34a; font-weight:700;">✔</span>` : ''; const hint = auto ? `<span class="dr-hsba-hint" style="color:#16a34a; font-size:12px;">(HSBA)</span>` : ''; if (auto) { const hl = (BS_CAI_DAT && BS_CAI_DAT.colors && BS_CAI_DAT.colors.blueCardBackground) ? BS_CAI_DAT.colors.blueCardBackground : '#e3f2fd'; liStyle += `background:${hl};`; } li.style = liStyle; li.innerHTML = `<label style="display:flex;align-items:center;gap:0;min-height:28px;"><input type="checkbox" id="${id}" ${isChecked ? 'checked' : ''}>${item}${auto ? ' ' : ''}${badge}${auto ? ' ' : ''}${hint}</label>`; checklistUl.appendChild(li); }); // Add sync note under list const note = document.createElement('div'); note.className = 'dr-hsba-sync-note'; note.style.cssText = 'margin-top:6px; font-size:12px; color:#64748b;'; if (lastSyncAt) { const dt = new Date(lastSyncAt); const dd = String(dt.getDate()).padStart(2,'0'); const mm = String(dt.getMonth()+1).padStart(2,'0'); const yyyy = dt.getFullYear(); const hh = String(dt.getHours()).padStart(2,'0'); const mi = String(dt.getMinutes()).padStart(2,'0'); note.textContent = `Đồng bộ HSBA: ${dd}/${mm}/${yyyy} ${hh}:${mi}`; } else { note.textContent = 'Đồng bộ HSBA: chưa có'; } checklistUl.parentElement.appendChild(note); // Setup checkbox change handlers setTimeout(() => { checklistUl.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.addEventListener('change', async function () { window.checklistState[this.parentNode.textContent.trim()] = this.checked; const res = await ChecklistService.updateChecklistState(window.checklistObj, window.checklistState, { enqueueOnOffline: true, ctxId: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.id), signal: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.signal) }); if (!res || (!res.ok && !res.queued)) { console.error('Lưu checklist thất bại!'); } }); }); }, 10); } // Public refresh to update HSBA badges and note after sync window.dr_refreshChecklistBadges = function () { try { const ul = document.querySelector('#checklist-bomo'); if (ul) renderChecklistItems(ul); } catch(_) {} }; function showSidebar(patient) { const backdrop = ModalManager.getOrCreateBackdrop(); const sidebar = ModalManager.getOrCreateSidebar(); // Clear and setup sidebar with responsive layout sidebar.innerHTML = ''; // Start a new session for this sidebar open const sessionId = SidebarSession.startSession(patient && patient.mabn); sidebar.style = `position:fixed;top:0;right:0;width:80vw;max-width:80vw;height:100vh;background:#fff;z-index:100000;box-shadow:-2px 0 16px rgba(0,0,0,0.15);padding:32px 24px 24px 24px;overflow-y:auto;transition:right 0.2s;`; // Create responsive container const container = document.createElement('div'); container.style.cssText = ` display: flex; flex-direction: column; gap: 20px; height: 100%; `; // Responsive styles are handled in addGlobalStyles() container.className = 'dr-sidebar-container'; // Left column: Patient info with surgery and y lệnh const leftColumn = document.createElement('div'); leftColumn.className = 'dr-sidebar-left'; leftColumn.style.cssText = ` flex: 1; min-width: 0; `; // Sidebar action buttons (reuse card actions behavior) const sidebarActions = document.createElement('div'); sidebarActions.className = 'dr-sidebar-actions'; sidebarActions.style.cssText = ` display: flex; justify-content: flex-end; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; `; // Import shared action creators const { createToDieuTriButton, createHsbaButton, createHsbaV1Button } = require('../components/actionButtons'); const { initCopyDienTienAI } = require('../components/copyDienTienAI'); sidebarActions.appendChild(createToDieuTriButton({ item: patient, variant: 'full' })); sidebarActions.appendChild(createHsbaV1Button(patient)); sidebarActions.appendChild(createHsbaButton({ item: patient, variant: 'full' })); // Copy diễn tiến button (AI) inside sidebar actions try { const btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.className = 'btn btn-sm btn-success'; btnCopy.textContent = 'Copy diễn tiến'; // Copy-again icon button const btnCopyAgain = document.createElement('button'); btnCopyAgain.type = 'button'; btnCopyAgain.title = 'Copy lại'; btnCopyAgain.className = 'btn btn-sm btn-outline-secondary'; btnCopyAgain.style.marginLeft = '6px'; btnCopyAgain.textContent = '📋'; btnCopyAgain.style.display = 'none'; btnCopy.addEventListener('click', async () => { // Build a minimal runner that reuses CopyDienTienAI logic with explicit mabn const mabn = (patient && (patient.pid || patient.mabn)) ? String(patient.pid || patient.mabn) : ''; const wrap = document.createElement('div'); const statusBar = document.createElement('div'); statusBar.id = 'dr-copy-dien-tien-status'; statusBar.style.cssText = 'margin-left:8px; font-size:12px; color:#0f172a;'; // Place status near the button btnCopyAgain.insertAdjacentElement('afterend', statusBar); if (!mabn) { const mod = require('../components/copyDienTienAI'); mod.setStatus(statusBar, 'Không tìm thấy MABN (pid)', '#b91c1c', true); return; } // Import functions from module const mod = require('../components/copyDienTienAI'); const { fetchPatientInfo } = mod.__esModule ? mod : { fetchPatientInfo: undefined }; // Fallback: call via window by reusing internal helpers through duplicated minimal flow try { mod.setStatus(statusBar, 'Đang lấy thông tin người bệnh...', '#0f172a', false); // use internal method via module reference already loaded in bundle const info = await mod.fetchPatientInfo(mabn); const mavaovien = info.maVaoVien || info.mavaovien || ''; const ngayvv = mod.parseMMDDYYYYtoDDMMYYYY(info.ngayVV || info.ngayvv || ''); const maql = info.maql || ''; if (!mavaovien || !ngayvv || !maql) { mod.setStatus(statusBar, 'Thiếu tham số (mã vào viện/ngày vào/maql)', '#b91c1c', true); return; } const denngay = mod.todayDDMMYYYY(); const pdfUrl = `/todieutri/DienBien/PrintPDF?id=&mabn=${encodeURIComponent(mabn)}&mavaovien=${encodeURIComponent(mavaovien)}&tungay=${encodeURIComponent(ngayvv)}&denngay=${encodeURIComponent(denngay)}&maql=${encodeURIComponent(maql)}`; mod.setStatus(statusBar, 'Đang tải và xử lý PDF...', '#0f172a', false); const buf = await mod.fetchPdfArrayBuffer(pdfUrl); const rawText = await mod.extractAllTextFromPdfBuffer(buf); const text = mod.sanitizeCopiedText(rawText); mod.setStatus(statusBar, 'Đang copy vào clipboard...', '#0f172a', false); const ok = await mod.copyToClipboard(text); if (ok) { mod.setStatus(statusBar, 'Đã copy toàn bộ diễn tiến vào clipboard.', '#166534', true); btnCopyAgain.dataset.clipboardText = text; btnCopyAgain.style.display = 'inline-block'; } else { mod.setStatus(statusBar, 'Không thể copy vào clipboard.', '#b91c1c', true); } } catch (err) { console.error(err); mod.setStatus(statusBar, 'Lỗi: ' + (err && err.message ? err.message : 'Không rõ'), '#b91c1c', true); } }); // Copy-again behavior btnCopyAgain.addEventListener('click', async () => { const mod = require('../components/copyDienTienAI'); const cached = btnCopyAgain.dataset.clipboardText || ''; const statusBar = document.getElementById('dr-copy-dien-tien-status') || document.createElement('div'); if (!cached) { mod.setStatus(statusBar, 'Chưa có dữ liệu để copy lại.', '#b91c1c', true); return; } mod.setStatus(statusBar, 'Đang copy vào clipboard...', '#0f172a', false); const ok = await mod.copyToClipboard(cached); if (ok) mod.setStatus(statusBar, 'Đã copy lại vào clipboard.', '#166534', true); else mod.setStatus(statusBar, 'Không thể copy vào clipboard.', '#b91c1c', true); }); sidebarActions.appendChild(btnCopy); sidebarActions.appendChild(btnCopyAgain); } catch(_) {} // HSBAv1 button now comes from components/actionButtons.js leftColumn.appendChild(sidebarActions); // Provide sidebar context for children (ctx id + abort signal) window.dr_sidebar_ctx = { id: sessionId, signal: SidebarSession.getSignal() }; const info = createPatientInfoSection(patient, quickYLenhActions); leftColumn.appendChild(info); // Setup phẫu thuật handlers for the info section setupPhauThuatHandlers(info, patient); // Right column: Checklist section const rightColumn = document.createElement('div'); rightColumn.className = 'dr-sidebar-right'; rightColumn.style.cssText = ` flex: 1; min-width: 0; `; const checklistDiv = createChecklistSection(patient); rightColumn.appendChild(checklistDiv); // Add HSBA Data tab into the same tabs bar try { const { addHSBATab } = require('../components/hsbaDataFetcher'); addHSBATab(checklistDiv, patient); } catch (e) { console.warn('HSBA tab init failed', e); } // Add columns to container container.appendChild(leftColumn); container.appendChild(rightColumn); // Add container to sidebar plus an offline banner const offlineBanner = document.createElement('div'); offlineBanner.className = 'dr-offline-banner'; offlineBanner.textContent = 'Đang offline — thay đổi sẽ được lưu tạm và đồng bộ khi có mạng.'; sidebar.appendChild(offlineBanner); // Add container to sidebar sidebar.appendChild(container); // Close button const closeBtn = ModalManager.setupCloseHandlers(sidebar, backdrop); sidebar.appendChild(closeBtn); // Show modal ModalManager.showModal(sidebar, backdrop); // Toggle offline banner visibility const toggleOffline = () => { try { const b = document.querySelector('#dr-sidebar .dr-offline-banner'); if (!b) return; b.style.display = (navigator && navigator.onLine === false) ? 'block' : 'none'; } catch(_) {} }; toggleOffline(); try { window.addEventListener('online', toggleOffline, { once: true }); } catch(_) {} } function renderCards(data) { const sortedData = PatientDataMapper.sortPatients([...data]); document.body.innerHTML = ''; // Create top filter/search bar const topBar = document.createElement('div'); topBar.className = 'dr-top-filter-bar'; topBar.style.cssText = ` position: sticky; top: 0; z-index: 1000; display: flex; align-items: center; gap: 12px; padding: 12px 16px; margin: 0 0 8px 0; background: #fff; border-bottom: 1px solid #e0e0e0; `; topBar.innerHTML = ` <div class="dr-topbar-left" style="display:flex; align-items:center; gap:12px; flex:1; min-width:0;"> <input id="dr-search-input" type="text" placeholder="Lọc BN theo tên, MABN, phòng, chẩn đoán..." style="flex:1; min-width: 220px; padding: 8px 10px; border:1px solid #ddd; border-radius:6px;"> </div> <div class="dr-topbar-center" style="flex:0 0 auto; display:flex; justify-content:center; min-width:140px;"> <span id="dr-total-compact" style="display:inline-block; text-align:center; color:#0f172a; font-weight:700; white-space:nowrap; background:#f1f5f9; border:1px solid #e2e8f0; padding:4px 10px; border-radius:9999px; min-width:110px;">0/0</span> </div> <div class="dr-topbar-right" style="flex:1; display:flex; align-items:center; justify-content:flex-end; gap:12px;"> <label style="display:flex; align-items:center; gap:6px; white-space:nowrap;"> <input id="dr-filter-xuatvien" type="checkbox"> Xuất viện </label> <label style="display:flex; align-items:center; gap:6px; white-space:nowrap;"> <input id="dr-filter-canlamsang" type="checkbox"> Cận lâm sàng </label> <button id="dr-view-toggle" title="Đổi chế độ hiển thị" style="padding:8px 10px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;cursor:pointer;white-space:nowrap;">Chế độ: <b><span id="dr-view-label"></span></b></button> </div> `; const container = document.createElement('div'); // View state const VIEW_KEY = 'dr-card-view'; const view = (localStorage.getItem(VIEW_KEY) || 'grid'); const viewLabelEl = topBar.querySelector('#dr-view-label'); const setViewLabel = () => { if (viewLabelEl) viewLabelEl.textContent = (localStorage.getItem(VIEW_KEY) || 'grid') === 'list' ? 'Danh sách' : 'Lưới'; }; if (!localStorage.getItem(VIEW_KEY)) localStorage.setItem(VIEW_KEY, view); container.className = view === 'list' ? 'dr-list-container' : 'dr-card-list'; // Safety padding in case styles load late container.style.paddingBottom = '90px'; const renderItemGrid = (item) => createPatientCard(item); const { createListRow } = require('../components/listView'); const renderItemList = (item) => createListRow(item, { onOpen: () => showSidebar(item) }); const renderer = (localStorage.getItem('dr-card-view') || 'grid') === 'list' ? renderItemList : renderItemGrid; sortedData.forEach(item => { const card = renderer(item); // mark useful attributes for filtering if (item && item.mabn) card.setAttribute('data-mabn', item.mabn); if (item && item.hoten) card.setAttribute('data-name', (item.hoten || '').toLowerCase()); if (item && item.chandoanvk) card.setAttribute('data-cd', (item.chandoanvk || '').toLowerCase()); if (item && (item.teN_PHONG || item.teN_GIUONG)) { const loc = PatientDataMapper.formatRoomLocation( item.teN_PHONG, item.teN_GIUONG, item.teN_TANG, item.teN_TOANHA ); card.setAttribute('data-loc', (loc || '').toLowerCase()); } // compute dataset flags from today's yLenhLog try { const today = new Date(); const todayStr = `${today.getDate().toString().padStart(2, '0')}/${(today.getMonth() + 1).toString().padStart(2, '0')}/${today.getFullYear()}`; const log = item && item.checklistState && Array.isArray(item.checklistState.yLenhLog) ? item.checklistState.yLenhLog : []; let hasXV = false, hasCLS = false; for (const e of log) { if (!e.timestamp || !e.content) continue; if (!e.timestamp.startsWith(todayStr)) continue; const c = e.content.toLowerCase(); if (c.includes('xuất viện')) { // if quick and has status, use done/active as presence if (e.q === true && e.action === 'Xuất viện') { if (e.status === 'active' || e.status === 'done') hasXV = true; } else { hasXV = true; } } if (c.includes('cận lâm sàng')) hasCLS = true; } card.dataset.hasxv = hasXV ? '1' : '0'; card.dataset.hascls = hasCLS ? '1' : '0'; } catch (_) {} container.appendChild(card); }); // Append top bar then container document.body.appendChild(topBar); document.body.appendChild(container); // Add bottom bar createBottomBar(); // Filter logic const searchInput = topBar.querySelector('#dr-search-input'); const chkXuatVien = topBar.querySelector('#dr-filter-xuatvien'); const chkCanLamSang = topBar.querySelector('#dr-filter-canlamsang'); const totalCompact = topBar.querySelector('#dr-total-compact'); function applyFilter() { const q = (searchInput.value || '').trim().toLowerCase(); const onlyXV = !!chkXuatVien.checked; const onlyCLS = !!chkCanLamSang.checked; let visible = 0; const cards = container.querySelectorAll('.dr-card, .dr-list-row'); cards.forEach(card => { const txt = card.textContent.toLowerCase(); const matchesText = q === '' || txt.includes(q) || card.getAttribute('data-mabn')?.toLowerCase().includes(q) || card.getAttribute('data-name')?.includes(q) || card.getAttribute('data-cd')?.includes(q) || card.getAttribute('data-loc')?.includes(q); // dataset flags prepared on card creation const matchesXV = !onlyXV || card.dataset.hasxv === '1' || card.classList.contains('xuatvienanimation'); const matchesCLS = !onlyCLS || card.dataset.hascls === '1'; const show = matchesText && matchesXV && matchesCLS; card.style.display = show ? '' : 'none'; if (show) visible++; }); // Update centered compact total, integrating the filter count if (totalCompact) { const hasFilter = !!(q || onlyXV || onlyCLS); totalCompact.textContent = hasFilter ? `Hiển thị: ${visible}/${sortedData.length}` : `${visible}/${sortedData.length}`; // Color accents: blue when filtered, neutral otherwise if (hasFilter) { totalCompact.style.background = '#e3f2fd'; totalCompact.style.borderColor = '#bbdefb'; totalCompact.style.color = '#1976d2'; } else { totalCompact.style.background = '#f1f5f9'; totalCompact.style.borderColor = '#e2e8f0'; totalCompact.style.color = '#0f172a'; } } } searchInput.addEventListener('input', applyFilter); chkXuatVien.addEventListener('change', applyFilter); chkCanLamSang.addEventListener('change', applyFilter); // Initialize view label, compact total and run first filter setViewLabel(); const totalCompactInit = document.getElementById('dr-total-compact'); if (totalCompactInit) { totalCompactInit.textContent = `${sortedData.length}/${sortedData.length}`; totalCompactInit.style.background = '#f1f5f9'; totalCompactInit.style.borderColor = '#e2e8f0'; totalCompactInit.style.color = '#0f172a'; } applyFilter(); // Prefill from query param ?q= try { const u = new URL(window.location.href); const qParam = u.searchParams.get('q'); if (qParam) { searchInput.value = qParam; applyFilter(); } } catch (_) {} const refreshPatientCards = function(newData) { const sortedNewData = PatientDataMapper.sortPatients([...newData]); // Update existing cards instead of full re-render to avoid interrupting user sortedNewData.forEach((item, index) => { const card = container.children[index]; if (card) { // Update merged diagnosis line (Chẩn đoán + CD kèm theo) try { const diagnosisEl = card.querySelector('.dr-diagnosis-line'); if (diagnosisEl) { const { composeDiagnosis } = DomUpdaters; const { baseText: baseCdNew, cdktText, combinedHtml } = composeDiagnosis(item); diagnosisEl.dataset.baseCd = baseCdNew; diagnosisEl.dataset.cdkt = cdktText || ''; diagnosisEl.innerHTML = `<span class="dr-label">Chẩn đoán:</span> ${combinedHtml}`; } // remove any legacy block if present const oldCdkt = card.querySelector('.dr-cdkt-block'); if (oldCdkt) oldCdkt.remove(); } catch (_) {} // Update surgery info with post-op days using shared updater DomUpdaters.updateSurgeryInfo(card, item); // Update HXT line in the card/list row DomUpdaters.updateHXT(item); // Update y lệnh tags if checklistState is available if (item.checklistState) { DomUpdaters.updateTagsAndMedsBadge(card, item); } // Update surgery status icon DomUpdaters.updateSurgeryIcon(card, item); // Re-evaluate filter visibility after updates (e.g., xuatvienanimation class changes) // Delay to allow DOM/class updates done elsewhere setTimeout(() => { applyFilter(); }, 0); } }); }; // Make functions available for global use if (typeof unsafeWindow !== 'undefined') { unsafeWindow.refreshPatientCards = refreshPatientCards; unsafeWindow.checkAllCelebrationAnimations = checkAllCelebrationAnimations; } else if (typeof this !== 'undefined') { this.refreshPatientCards = refreshPatientCards; this.checkAllCelebrationAnimations = checkAllCelebrationAnimations; } else { globalThis.refreshPatientCards = refreshPatientCards; globalThis.checkAllCelebrationAnimations = checkAllCelebrationAnimations; } // Wire view toggle button const toggleBtn = topBar.querySelector('#dr-view-toggle'); if (toggleBtn) { toggleBtn.addEventListener('click', () => { const cur = localStorage.getItem('dr-card-view') || 'grid'; const next = cur === 'list' ? 'grid' : 'list'; localStorage.setItem('dr-card-view', next); setViewLabel(); try { window.location.reload(); } catch(_) { } }); } } function createPatientCard(item) { const room = item.teN_PHONG || ''; const isWhite = PatientDataMapper.isWhiteCard(room); const card = document.createElement('div'); card.className = 'dr-card' + (isWhite ? '' : ' dr-blue'); // Format location using the new utility function const formattedLocation = PatientDataMapper.formatRoomLocation( item.teN_PHONG, item.teN_GIUONG, item.teN_TANG, item.teN_TOANHA ); const ptInfo = formatSurgeryInfo(item); const hxtText = (item.checklistState && item.checklistState.huongXuTri) ? String(item.checklistState.huongXuTri).trim() : ''; const hxtHtml = hxtText ? `<div class="dr-value dr-hxt-block"><span class="dr-label"><b>HXT:</b></span> ${escapeHtml(hxtText)}</div>` : ''; const { baseText: baseDiagnosis, cdktText, combinedHtml: combinedDiagnosis } = DomUpdaters.composeDiagnosis(item); card.innerHTML = ` <h2>${item.hoten || ''} <span style="font-size:0.9em;color:#888;">${item.mabn ? ' - ' + item.mabn : ''}</span> - ${item.phai === 1 ? 'Nữ' : 'Nam'} - ${formattedLocation}</h2> <div class="dr-value"><span class="dr-label">Ngày sinh:</span> ${item.ngaysinh ? Utils.formatDate(item.ngaysinh) : ''} (${Utils.calculateAge(item.ngaysinh)} tuổi)</div> <div class="dr-value dr-diagnosis-line" data-base-cd="${baseDiagnosis.replace(/"/g,'"')}" data-cdkt="${escapeHtml(cdktText).replace(/"/g,'"')}"><span class="dr-label">Chẩn đoán:</span> ${combinedDiagnosis}</div> ${ptInfo} ${hxtHtml} ${createYLenhTags(item)} `; // mark base diagnosis for future updates try { const diagEl = card.querySelector('.dr-diagnosis-line'); if (diagEl) { diagEl.dataset.baseCd = baseDiagnosis; diagEl.dataset.cdkt = cdktText || ''; } } catch (_) {} if (item && item.mabn && !card.getAttribute('data-mabn')) { card.setAttribute('data-mabn', item.mabn); } // Add action buttons const btnGroup = createActionButtons(item); card.appendChild(btnGroup); // Add surgery status icon addSurgeryStatusIcon(card, item); // Show meds-done badge if applicable try { updateMedsDoneBadge(card, item); } catch (_) {} card.onclick = () => showSidebar(item); // Preload HXT from checklist state after rendering card (non-blocking) setTimeout(() => { preloadHXTForPatient(item); }, 0); return card; } // List view row now lives in components/listView.js // escapeHtml provided by utils/htmlUtils // Update HXT on a card when sidebar saves function updatePatientCardHXT(patient) { try { DomUpdaters.updateHXT(patient); } catch (_) {} } // Update Chẩn đoán kèm theo on a card when sidebar saves function updatePatientCardCDKT(patient) { try { DomUpdaters.updateCDKT(patient); } catch (_) {} } // Preload HXT for a patient by fetching checklist state if not present async function preloadHXTForPatient(item) { try { if (!item || !item.mabn) return; const existing = item.checklistState && typeof item.checklistState.huongXuTri === 'string' ? item.checklistState.huongXuTri.trim() : ''; if (existing) { DomUpdaters.updateHXT(item); return; } const res = await ChecklistService.loadChecklistData(item); const obj = ChecklistService.findChecklistObject(res); if (!obj) return; const state = ChecklistService.parseChecklistState(obj) || {}; const hxt = typeof state.huongXuTri === 'string' ? state.huongXuTri.trim() : ''; if (!hxt) return; // Update dr_data entry if (window.dr_data && Array.isArray(window.dr_data)) { const idx = window.dr_data.findIndex(p => p.mabn === item.mabn); if (idx >= 0) { const oldState = window.dr_data[idx].checklistState || {}; window.dr_data[idx].checklistState = { ...oldState, ...state }; } } // Update card view with merged state const updated = { ...item, checklistState: { ...(item.checklistState || {}), ...state } }; DomUpdaters.updateHXT(updated); try { DomUpdaters.updateCDKT(updated); } catch (_) {} } catch (e) { console.warn('Preload HXT failed for', item?.mabn, e); } } // Helper function to create action buttons function createActionButtons(item) { const { createToDieuTriButton, createHsbaButton, createCopyOneButton } = require('../components/actionButtons'); const btnToDieuTri = createToDieuTriButton({ item, variant: 'full' }); const btnHsba2 = createHsbaButton({ item, variant: 'full' }); const btnCopyOne = createCopyOneButton({ item, variant: 'icon' }); const btnGroup = document.createElement('div'); btnGroup.className = 'dr-action-buttons'; btnGroup.style.display = 'flex'; btnGroup.style.gap = '8px'; btnGroup.style.justifyContent = 'flex-end'; btnGroup.style.alignItems = 'center'; btnGroup.style.position = 'absolute'; btnGroup.style.right = '16px'; btnGroup.style.bottom = '12px'; btnGroup.appendChild(btnCopyOne); btnGroup.appendChild(btnToDieuTri); btnGroup.appendChild(btnHsba2); return btnGroup; } // Button creators moved to components/actionButtons.js // Helper function to create bottom bar function createBottomBar() { const ApiService = require('../services/apiService'); const { getSelectedKhoa } = require('../utils/khoaUtils'); const bottomBar = document.createElement('div'); bottomBar.className = 'dr-bottom-bar'; bottomBar.innerHTML = ` <div class="dr-bottom-bar-left"> <a id="dr-settings-btn" class="dr-gear-btn" href="/?caidat" target="_blank" title="Cài đặt"> <i class="fas fa-cog"></i> </a> <select id="dr-khoa-select" class="dr-khoa-select" title="Chọn khoa"></select> </div> <button id="dr-btn-direct-report" class="btn btn-warning" style="font-weight:bold;">Tạo báo cáo trực</button> `; document.body.appendChild(bottomBar); // Add OTM buttons to bottom bar addOTMButtonsToBottomBar(bottomBar); // Bottom bar styles come from addGlobalStyles() // Setup direct report button setTimeout(() => { const btn = document.getElementById('dr-btn-direct-report'); if (btn) btn.onclick = createDirectReportGeneration; }, 10); // Populate khoa dropdown and wire change (async () => { try { const select = document.getElementById('dr-khoa-select'); if (!select) return; select.disabled = true; select.innerHTML = `<option>Đang tải khoa...</option>`; const list = await ApiService.fetchKhoaPhong(); const current = String(getSelectedKhoa('551')); select.innerHTML = ''; list.forEach(k => { const opt = document.createElement('option'); opt.value = String(k.id); opt.textContent = k.name || k.id; if (opt.value === current) opt.selected = true; select.appendChild(opt); }); select.disabled = false; select.addEventListener('change', (e) => { const val = e.target.value; try { localStorage.setItem('bsnt_khoa_dashboard', String(val)); } catch(_) {} // reload dashboard data by simply reloading the page or re-running init window.location.reload(); }); } catch (e) { console.warn('Load khoa for bottom bar failed', e); } })(); } // Bottom bar styling helper removed (centralized in dashboard.support.js) // Main logic async function initializeDashboard() { const data = await PatientService.loadPatientDataWithErrorHandling(); if (data) { renderCards(data); } } // Helper function to try closing a tab using multiple methods function tryCloseTab(tab) { // Method 1: Try GM.closeTab with tab object if (typeof GM !== 'undefined' && GM.closeTab) { try { GM.closeTab(tab); console.log('Closed OTM tab using GM.closeTab(tab)'); return false; // Remove from array } catch (gmError) { console.log('GM.closeTab(tab) failed, trying alternatives:', gmError); } } // Method 2: Try tab.close() if available if (tab.close && typeof tab.close === 'function') { try { tab.close(); console.log('Closed OTM tab using tab.close()'); return false; // Remove from array } catch (closeError) { console.log('tab.close() failed:', closeError); } } // Method 3: Try window.close() on the tab if (tab.window && tab.window.close) { try { tab.window.close(); console.log('Closed OTM tab using tab.window.close()'); return false; // Remove from array } catch (windowError) { console.log('tab.window.close() failed:', windowError); } } // Method 4: For GM tabs, try posting a message to close if (tab.postMessage) { try { tab.postMessage({ type: 'close-otm-tab' }, '*'); console.log('Sent close message to OTM tab'); return false; // Remove from array } catch (msgError) { console.log('postMessage failed:', msgError); } } console.log('All close methods failed for OTM tab'); return true; // Keep in array } // Handle OTM close tab messages function handleOTMCloseTab(name, oldValue, newValue, remote) { try { const data = typeof newValue === 'string' ? JSON.parse(newValue) : newValue; console.log('[OTM Close Tab] Received close request:', data.data); console.log('[OTM Close Tab] Current openTabs:', window.openTabs); console.log('[OTM Close Tab] openTabs length:', window.openTabs.length); // Close OTM tabs from stored references if (window.openTabs && window.openTabs.length > 0) { window.openTabs = window.openTabs.filter(tabInfo => { if (tabInfo && tabInfo.hostname === 'otm.tahospital.vn') { try { const tab = tabInfo.tab; // Handle case where tab is a Promise (from GM.openInTab) if (tab && typeof tab.then === 'function') { console.log('Tab is a Promise, waiting for resolution...'); tab.then(actualTab => { if (actualTab && !actualTab.closed) { tryCloseTab(actualTab); } }).catch(error => { console.error('Error resolving tab Promise:', error); }); return false; // Remove from array since we're handling it asynchronously } if (tab && !tab.closed) { return tryCloseTab(tab); } else { console.log('Tab already closed or invalid'); return false; // Remove from array } } catch (error) { console.error('Error closing OTM tab:', error); return true; // Keep in array } } return true; // Keep in array }); } else { console.log('No OTM tabs found to close'); } } catch (error) { console.error('Error handling OTM close tab:', error); } } // Add GM value change listeners for OTM data if (typeof GM !== 'undefined' && GM.addValueChangeListener) { GM.addValueChangeListener('otm_progress', handleOTMProgress); GM.addValueChangeListener('otm_success', handleOTMSuccess); GM.addValueChangeListener('otm_error', handleOTMError); GM.addValueChangeListener('otm_close_tab', handleOTMCloseTab); } else { // Fallback to localStorage polling for non-Greasemonkey environments setInterval(() => { const progressData = localStorage.getItem('otm_progress'); const successData = localStorage.getItem('otm_success'); const errorData = localStorage.getItem('otm_error'); const closeTabData = localStorage.getItem('otm_close_tab'); if (progressData) { try { const parsed = JSON.parse(progressData); handleOTMProgress('otm_progress', null, parsed, null); localStorage.removeItem('otm_progress'); } catch (e) { console.error('Error parsing OTM progress data:', e); } } if (successData) { try { const parsed = JSON.parse(successData); handleOTMSuccess('otm_success', null, parsed, null); localStorage.removeItem('otm_success'); } catch (e) { console.error('Error parsing OTM success data:', e); } } if (errorData) { try { const parsed = JSON.parse(errorData); handleOTMError('otm_error', null, parsed, null); localStorage.removeItem('otm_error'); } catch (e) { console.error('Error parsing OTM error data:', e); } } if (closeTabData) { try { const parsed = JSON.parse(closeTabData); handleOTMCloseTab('otm_close_tab', null, parsed, null); localStorage.removeItem('otm_close_tab'); } catch (e) { console.error('Error parsing OTM close tab data:', e); } } }, 1000); } // Start dashboard initialization initializeDashboard(); } // Helpers to integrate standardized OTM surgery data function dr_normalizePid(val) { if (val == null) return ''; const s = String(val).trim(); const digits = s.replace(/\D+/g, ''); return digits.replace(/^0+/, ''); } function dr_formatVNDateTime(date) { const dd = String(date.getDate()).padStart(2, '0'); const mm = String(date.getMonth() + 1).padStart(2, '0'); const yyyy = date.getFullYear(); const HH = String(date.getHours()).padStart(2, '0'); const MM = String(date.getMinutes()).padStart(2, '0'); return { date: `${dd}/${mm}/${yyyy}`, time: `${HH}:${MM}` }; } function dr_otmToLogEntry(otmItem) { try { const startIso = otmItem && otmItem.start; if (!startIso) return null; const d = new Date(startIso); if (isNaN(d.getTime())) return null; const { date, time } = dr_formatVNDateTime(d); const method = (otmItem.surgerymethod || '').trim(); // Collect doctors from userexec + userassistant const names = []; const pushNames = (arr) => { if (Array.isArray(arr)) { for (const u of arr) { const n = (u && u.fullname ? String(u.fullname) : '').trim(); if (n && !names.includes(n)) names.push(n); } } }; pushNames(otmItem.userexec); pushNames(otmItem.userassistant); const doctors = names.join(', '); return { date, time, method, doctors, id: `otm-${startIso}` }; } catch (_) { return null; } } function dr_integrateOTMSurgeryData(otmList) { const res = { updatedPatients: 0, addedLogs: 0, updated: [] }; if (!Array.isArray(otmList) || !Array.isArray(window.dr_data)) return res; // Build map pid -> log entries const map = new Map(); for (const it of otmList) { const pid = dr_normalizePid(it && it.customer && it.customer.pid); if (!pid) continue; const entry = dr_otmToLogEntry(it); if (!entry) continue; if (!map.has(pid)) map.set(pid, []); map.get(pid).push(entry); } if (map.size === 0) return res; // Attach and merge to in-memory patient data for (const p of window.dr_data) { const mabnNorm = dr_normalizePid(p && p.mabn); if (!mabnNorm) continue; const entries = map.get(mabnNorm); if (!entries || entries.length === 0) continue; // Keep original for sidebar merge p._otmPhauThuatLog = entries.slice(); // Merge into patient.checklistState for UI display (append-only, no overwrite) if (!p.checklistState) p.checklistState = {}; if (!Array.isArray(p.checklistState.phauThuatLog)) p.checklistState.phauThuatLog = []; const keyOf = (e) => `${e.date}|${e.time}|${(e.method||'').trim().toLowerCase()}`; const existingKeys = new Set(p.checklistState.phauThuatLog.map(keyOf)); let added = 0; for (const e of entries) { const k = keyOf(e); if (!existingKeys.has(k)) { p.checklistState.phauThuatLog.push({ ...e }); existingKeys.add(k); added++; } } if (added > 0) { res.updatedPatients++; res.addedLogs += added; res.updated.push({ patient: p, added }); // Sort newest first const parseDDMMYYYY = (s) => { const [d,m,y] = String(s||'').split('/').map(n=>parseInt(n,10)); return new Date(y||1970,(m||1)-1,d||1); }; const toTs = (e) => { const dt = parseDDMMYYYY(e.date); const [hh,mm] = String(e.time||'00:00').split(':').map(n=>parseInt(n,10)||0); dt.setHours(hh, mm, 0, 0); return dt.getTime(); }; p.checklistState.phauThuatLog.sort((a,b) => toTs(b)-toTs(a)); // Also reflect latest to phauThuatInfo for formatSurgeryInfo compatibility const latest = p.checklistState.phauThuatLog[0]; if (latest) { p.phauThuatInfo = { date: latest.date, time: latest.time, method: latest.method, doctors: latest.doctors, ngayPhauThuat: latest.date, gioPhauThuat: latest.time, pppt: latest.method }; } // Update card/list row if present try { const DomUpdaters = require('../utils/domUpdaters'); const el = DomUpdaters.findPatientElement(p.mabn); if (el) { DomUpdaters.updateSurgeryInfo(el, p); DomUpdaters.updateSurgeryIcon(el, p); } } catch (_) {} } } return res; } async function dr_fetchChecklistObjForPatient(patient) { try { const res = await ChecklistService.loadChecklistData(patient, { forceRefresh: true }); let obj = ChecklistService.findChecklistObject(res); if (!obj) { const created = await ChecklistService.createNewChecklist(patient); if (created) { const res2 = await ChecklistService.loadChecklistData(patient, { forceRefresh: true }); obj = ChecklistService.findChecklistObject(res2); } } return obj || null; } catch (_) { return null; } } async function dr_persistMergedOTMSurgeries(updatedEntries, { concurrency = 2 } = {}) { if (!Array.isArray(updatedEntries) || updatedEntries.length === 0) return { saved: 0, queued: 0, failed: 0 }; const queue = updatedEntries.slice(); let saved = 0, queued = 0, failed = 0; const worker = async () => { while (queue.length) { const entry = queue.shift(); const p = entry && entry.patient; if (!p) { failed++; continue; } try { const checklistObj = await dr_fetchChecklistObjForPatient(p); if (!checklistObj) { failed++; continue; } // Merge server state with current in-memory state (append-only) const serverState = ChecklistService.parseChecklistState(checklistObj) || {}; const ensureArr = (arr) => Array.isArray(arr) ? arr : []; const merged = ensureArr(serverState.phauThuatLog).slice(); const fromMem = ensureArr(p.checklistState && p.checklistState.phauThuatLog); const keyOf = (e) => `${e.date}|${e.time}|${(e.method||'').trim().toLowerCase()}`; const existing = new Set(merged.map(keyOf)); for (const e of fromMem) { const k = keyOf(e); if (!existing.has(k)) { merged.push({ ...e }); existing.add(k); } } // Sort newest first const parseDDMMYYYY = (s) => { const [d,m,y] = String(s||'').split('/').map(n=>parseInt(n,10)); return new Date(y||1970,(m||1)-1,d||1); }; const toTs = (e) => { const dt = parseDDMMYYYY(e.date); const [hh,mm] = String(e.time||'00:00').split(':').map(n=>parseInt(n,10)||0); dt.setHours(hh, mm, 0, 0); return dt.getTime(); }; merged.sort((a,b) => toTs(b)-toTs(a)); const newState = { ...(serverState || {}), phauThuatLog: merged }; const r = await ChecklistService.updateChecklistState(checklistObj, newState, { enqueueOnOffline: true }); if (r && (r.ok || r.queued)) { if (r.queued) queued++; else saved++; } else { failed++; } } catch (_) { failed++; } } }; const workers = Array.from({ length: Math.max(1, Math.min(6, concurrency)) }, () => worker()); await Promise.all(workers); return { saved, queued, failed }; } // Handle OTM progress messages function handleOTMProgress(name, oldValue, newValue, remote) { try { const data = typeof newValue === 'string' ? JSON.parse(newValue) : newValue; showToast(`🔄 ${data.data.message}`, 'info', 3000); console.log('[OTM Progress]', data.data.step, data.data.message); } catch (error) { console.error('Error handling OTM progress:', error); } } // Handle OTM success messages function handleOTMSuccess(name, oldValue, newValue, remote) { try { const data = typeof newValue === 'string' ? JSON.parse(newValue) : newValue; showToast(`✅ ${data.data.count} ca mổ đã được tải về!`, 'success', 5000); console.log('[OTM Success]', data.data); // Log full dataset once (no per-patient logs) if (data.data.surgeryData && data.data.surgeryData.length > 0) { console.log('=== SURGERY DATA RECEIVED (FULL) ===', data.data.surgeryData); // Merge into in-memory patients and update UI const mergeRes = dr_integrateOTMSurgeryData(data.data.surgeryData); const { updatedPatients, addedLogs } = mergeRes; if (updatedPatients > 0) { try { showToast(`🧩 Đã cập nhật PT cho ${updatedPatients} BN (${addedLogs} mục).`, 'success', 4000); } catch (_) {} // Persist to server in background (append-only) (async () => { const res = await dr_persistMergedOTMSurgeries(mergeRes.updated, { concurrency: 2 }); if ((res.saved + res.queued) > 0) { try { showToast(`💾 Lưu ${res.saved} | Hàng đợi ${res.queued} | Lỗi ${res.failed}`, 'info', 4000); } catch (_) {} } })(); } } } catch (error) { console.error('Error handling OTM success:', error); } } // Handle OTM error messages function handleOTMError(name, oldValue, newValue, remote) { try { const data = typeof newValue === 'string' ? JSON.parse(newValue) : newValue; showToast(`❌ ${data.data.message}`, 'error', 5000); console.error('[OTM Error]', data.data); } catch (error) { console.error('Error handling OTM error:', error); } } // OTM buttons integration function addOTMButtonsToBottomBar(bottomBar) { const bottomBarLeft = bottomBar.querySelector('.dr-bottom-bar-left'); if (!bottomBarLeft) return; // Date range button (integrated today functionality) const dateBtn = document.createElement('button'); dateBtn.id = 'dr-otm-date-btn'; dateBtn.className = 'dr-btn dr-otm-btn'; dateBtn.textContent = 'Mổ theo ngày'; dateBtn.title = 'Chọn khoảng thời gian để lấy dữ liệu mổ từ OTM'; dateBtn.addEventListener('click', () => handleOTMDateClick()); bottomBarLeft.appendChild(dateBtn); console.log('OTM button added to dashboard'); function handleOTMDateClick() { const DialogManager = require('../components/dialogManager'); const dialog = DialogManager.createDialog('otm-date-dialog'); // Get today's date in YYYY-MM-DD format const today = new Date().toISOString().split('T')[0]; dialog.inner.innerHTML = ` <h3>Chọn khoảng thời gian</h3> <div style="margin: 10px 0;"> <div style="margin-bottom: 10px;"> <label for="otm-start-date">Ngày bắt đầu:</label> <input type="date" id="otm-start-date" style="margin-left: 10px;" value="${today}"> </div> <div> <label for="otm-end-date">Ngày kết thúc:</label> <input type="date" id="otm-end-date" style="margin-left: 10px;" value="${today}"> </div> </div> `; const actionButtons = DialogManager.createActionButtons([ { id: 'otm-fetch-btn', className: 'dr-btn-primary', text: 'Lấy dữ liệu', onclick: () => { const startDateInput = document.getElementById('otm-start-date'); const endDateInput = document.getElementById('otm-end-date'); if (startDateInput && startDateInput.value && endDateInput && endDateInput.value) { openOTMTab(startDateInput.value, endDateInput.value); dialog.close(); } else { alert('Vui lòng chọn ngày bắt đầu và ngày kết thúc'); } } }, { id: 'otm-cancel-btn', className: 'dr-btn-secondary', text: 'Hủy', onclick: () => dialog.close() } ]); dialog.inner.appendChild(actionButtons); dialog.show(); } function openOTMTab(fromDate, toDate) { const url = `https://otm.tahospital.vn/?otm-fetch=${encodeURIComponent(JSON.stringify({ fromDate, toDate }))}`; console.log('[OTM Open Tab] Opening tab with URL:', url); console.log('[OTM Open Tab] Current openTabs before:', window.openTabs); if (typeof GM !== 'undefined' && GM.openInTab) { const tabPromise = GM.openInTab(url, { active: false, insert: true, setParent: true }); // Handle the Promise returned by GM.openInTab if (tabPromise && typeof tabPromise.then === 'function') { tabPromise.then(tab => { if (tab) { window.openTabs.push({ tab: tab, url: url, openedAt: Date.now(), hostname: 'otm.tahospital.vn' }); console.log('[OTM Open Tab] Tab added to openTabs. New length:', window.openTabs.length); } else { console.log('[OTM Open Tab] GM.openInTab resolved to null/undefined'); } }).catch(error => { console.error('[OTM Open Tab] Error opening tab:', error); }); } else if (tabPromise) { // Fallback if it's not a Promise (older GM versions) window.openTabs.push({ tab: tabPromise, url: url, openedAt: Date.now(), hostname: 'otm.tahospital.vn' }); console.log('[OTM Open Tab] Tab added to openTabs. New length:', window.openTabs.length); } else { console.log('[OTM Open Tab] GM.openInTab returned null/undefined'); } } else { // Fallback for non-Greasemonkey environments const tab = window.open(url, '_blank'); if (tab) { window.openTabs.push({ tab: tab, url: url, openedAt: Date.now(), hostname: 'otm.tahospital.vn' }); console.log('[OTM Open Tab] Fallback tab added to openTabs. New length:', window.openTabs.length); } else { console.log('[OTM Open Tab] window.open returned null/undefined'); } } } } module.exports = { showDashboardBenhNhanIfNeeded }; }).call(this)}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) },{"../BS_CAI_DAT_GIAO_DIEN":1,"../components/actionButtons":4,"../components/copyDienTienAI":6,"../components/dialogManager":7,"../components/hsbaDataFetcher":8,"../components/listView":10,"../components/loginHandler":11,"../components/modalManager":12,"../components/patientInfoSection":13,"../components/phauThuatHandlers":14,"../components/sidebarSession":15,"../services/apiService":26,"../services/checklistService":27,"../services/patientService":28,"../utils":34,"../utils/checklistUtils":35,"../utils/domUpdaters":37,"../utils/htmlUtils":38,"../utils/khoaUtils":39,"../utils/patientDataMapper":40,"../utils/surgeryUtils":41,"../utils/tagUtils":42,"../utils/uiUtils":43,"./page.dashboard.support":21}],21:[function(require,module,exports){ // dashboard.support.js - Refactored with modular architecture const ReportService = require('../services/reportService'); const ApiService = require('../services/apiService'); const DialogManager = require('../components/dialogManager'); const DateUtils = require('../utils/dateUtils'); /** * Create direct report generation dialog */ async function createDirectReportGeneration() { const data = window.dr_data || []; // Create dialog const { dialog, inner } = DialogManager.createDialog('dr-direct-report-dialog', { maxWidth: '1100px', maxHeight: '88vh' }); // Layout: flex column with a scrollable content area and a fixed (in-modal) footer try { inner.style.display = 'flex'; inner.style.flexDirection = 'column'; inner.style.overflowY = 'hidden'; inner.style.paddingBottom = '0px'; } catch (_) {} try { // Show loading state inner.innerHTML = ` <div style="font-size:1.1em;margin-bottom:12px"><b>BÁO CÁO TRỰC</b></div> <div style="text-align:center;padding:20px;"> <div>Đang tải dữ liệu báo cáo...</div> </div> `; // Load checklist state for all patients (already sorted) const { sortedPatients, states } = await ReportService.getBatchChecklistStates(data); // Generate report content (all patients) const htmlContent = ReportService.generateHTMLReport(sortedPatients, states); const textReport = ReportService.generateTextReport(sortedPatients, states); // Helpers to filter patients by admission date (ngayvv) using preloaded data only function parseAdmitDateToMidnight(dateStr) { if (!dateStr) return null; try { const us = DateUtils.convertToUSFormat(String(dateStr)); const d = new Date(us); if (isNaN(d.getTime())) return null; d.setHours(0, 0, 0, 0); return d; } catch (_) { return null; } } function filterByAdmitDay(patientsArr, statesArr, targetDate) { const target = new Date(targetDate); target.setHours(0,0,0,0); const zipped = patientsArr.map((p, i) => ({ p, s: statesArr[i] })); const filtered = zipped.filter(({ p }) => { const d = parseAdmitDateToMidnight(p && p.ngayvv); return d && d.getTime() === target.getTime(); }); return { patients: filtered.map(z => z.p), states: filtered.map(z => z.s) }; } const today = new Date(); today.setHours(0,0,0,0); const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); const { patients: todayPatients, states: todayStates } = filterByAdmitDay(sortedPatients, states, today); const { patients: yesterdayPatients, states: yesterdayStates } = filterByAdmitDay(sortedPatients, states, yesterday); const htmlToday = ReportService.generateHTMLReport(todayPatients, todayStates); const textToday = ReportService.generateTextReport(todayPatients, todayStates); const htmlYesterday = ReportService.generateHTMLReport(yesterdayPatients, yesterdayStates); const textYesterday = ReportService.generateTextReport(yesterdayPatients, yesterdayStates); // Filter by surgery date (latest surgery in state.phauThuatLog[0]) function filterBySurgeryDay(patientsArr, statesArr, targetDate) { const target = new Date(targetDate); target.setHours(0,0,0,0); const zipped = patientsArr.map((p, i) => ({ p, s: statesArr[i] })); const filtered = zipped.filter(({ s }) => { if (!s || !Array.isArray(s.phauThuatLog) || s.phauThuatLog.length === 0) return false; const dStr = s.phauThuatLog[0] && s.phauThuatLog[0].date; const d = parseAdmitDateToMidnight(dStr); return d && d.getTime() === target.getTime(); }); return { patients: filtered.map(z => z.p), states: filtered.map(z => z.s) }; } const { patients: ptTodayPatients, states: ptTodayStates } = filterBySurgeryDay(sortedPatients, states, today); const { patients: ptYesterdayPatients, states: ptYesterdayStates } = filterBySurgeryDay(sortedPatients, states, yesterday); const htmlPtToday = ReportService.generateHTMLReport(ptTodayPatients, ptTodayStates); const textPtToday = ReportService.generateTextReport(ptTodayPatients, ptTodayStates); const htmlPtYesterday = ReportService.generateHTMLReport(ptYesterdayPatients, ptYesterdayStates); const textPtYesterday = ReportService.generateTextReport(ptYesterdayPatients, ptYesterdayStates); // Create action buttons (copy set only) const copyButtons = DialogManager.createActionButtons([ { id: 'dr-copy-direct-report', className: 'btn btn-primary', text: 'Copy bệnh ở khoa', onclick: () => copyReportToClipboardRich(htmlContent, textReport) }, { id: 'dr-copy-direct-report-yesterday', className: 'btn btn-secondary', text: 'Copy bệnh mới hôm qua', onclick: () => { if (!yesterdayPatients || yesterdayPatients.length === 0) { try { DialogManager.showToast('Không có bệnh nhân mới hôm qua.'); } catch (_) {} return; } copyReportToClipboardRich(htmlYesterday, textYesterday); } }, { id: 'dr-copy-direct-report-today', className: 'btn btn-secondary', text: 'Copy bệnh mới hôm nay', onclick: () => { if (!todayPatients || todayPatients.length === 0) { try { DialogManager.showToast('Không có bệnh nhân mới hôm nay.'); } catch (_) {} return; } copyReportToClipboardRich(htmlToday, textToday); } }, { id: 'dr-copy-direct-report-pt-yesterday', className: 'btn btn-secondary', text: 'Copy bệnh PT hôm qua', onclick: () => { if (!ptYesterdayPatients || ptYesterdayPatients.length === 0) { try { DialogManager.showToast('Không có bệnh nhân PT hôm qua.'); } catch (_) {} return; } copyReportToClipboardRich(htmlPtYesterday, textPtYesterday); } }, { id: 'dr-copy-direct-report-pt-today', className: 'btn btn-secondary', text: 'Copy bệnh PT hôm nay', onclick: () => { if (!ptTodayPatients || ptTodayPatients.length === 0) { try { DialogManager.showToast('Không có bệnh nhân PT hôm nay.'); } catch (_) {} return; } copyReportToClipboardRich(htmlPtToday, textPtToday); } } ]); // Update dialog content: a scrollable content area inner.innerHTML = `<div id="dr-report-content" style="flex:1; overflow:auto;">${htmlContent}</div>`; // Build footer bar fixed within modal (not sticky) const footerBar = document.createElement('div'); footerBar.style.cssText = [ 'background:#fff', 'padding:10px 0 0', 'margin-top:8px', 'border-top:1px solid #eee', 'box-shadow:0 -2px 8px rgba(0,0,0,0.05)' ].join(';'); // Arrange copy buttons into a 2x3 grid as requested try { const grid = copyButtons; grid.style.display = 'grid'; grid.style.gridTemplateColumns = '1fr 1fr 1fr'; grid.style.gridTemplateRows = 'auto auto'; grid.style.gap = '12px'; grid.style.justifyContent = 'stretch'; grid.style.alignItems = 'stretch'; const btnAll = grid.querySelector('#dr-copy-direct-report'); const btnNewY = grid.querySelector('#dr-copy-direct-report-yesterday'); const btnNewT = grid.querySelector('#dr-copy-direct-report-today'); const btnPtY = grid.querySelector('#dr-copy-direct-report-pt-yesterday'); const btnPtT = grid.querySelector('#dr-copy-direct-report-pt-today'); if (btnAll) { btnAll.style.gridColumn = '1'; btnAll.style.gridRow = '1 / span 2'; btnAll.style.height = '100%'; btnAll.style.width = '100%'; } if (btnNewY) { btnNewY.style.gridColumn = '2'; btnNewY.style.gridRow = '1'; btnNewY.style.width = '100%'; } if (btnNewT) { btnNewT.style.gridColumn = '2'; btnNewT.style.gridRow = '2'; btnNewT.style.width = '100%'; } if (btnPtY) { btnPtY.style.gridColumn = '3'; btnPtY.style.gridRow = '1'; btnPtY.style.width = '100%'; } if (btnPtT) { btnPtT.style.gridColumn = '3'; btnPtT.style.gridRow = '2'; btnPtT.style.width = '100%'; } } catch (_) {} if (copyButtons && copyButtons.style) copyButtons.style.marginTop = '0'; footerBar.appendChild(copyButtons); // Add a separate right-aligned close button row const closeRow = document.createElement('div'); closeRow.style.cssText = 'display:flex;justify-content:flex-end;margin-top:8px;'; const closeBtnWrap = DialogManager.createActionButtons([ { id: 'dr-close-direct-report', className: 'btn btn-secondary', text: 'Đóng', onclick: () => dialog.remove() } ]); // Flatten wrapper styles if (closeBtnWrap && closeBtnWrap.style) { closeBtnWrap.style.marginTop = '0'; } closeRow.appendChild(closeBtnWrap); footerBar.appendChild(closeRow); inner.appendChild(footerBar); } catch (error) { console.error('Error generating report:', error); inner.innerHTML = ` <div style="font-size:1.1em;margin-bottom:12px"><b>BÁO CÁO TRỰC</b></div> <div style="color:red;text-align:center;padding:20px;"> Có lỗi xảy ra khi tạo báo cáo. Vui lòng thử lại. </div> ${DialogManager.createActionButtons([{ id: 'dr-close-direct-report', className: 'btn btn-secondary', text: 'Đóng', onclick: () => dialog.remove() }]).outerHTML} `; } } /** * Copy rich HTML (with plain text fallback) to clipboard for better pasting into Google Docs */ async function copyReportToClipboardRich(html, textFallback) { try { if (navigator.clipboard && window.ClipboardItem) { const blobHTML = new Blob([html], { type: 'text/html' }); const blobText = new Blob([textFallback || ''], { type: 'text/plain' }); const data = new ClipboardItem({ 'text/html': blobHTML, 'text/plain': blobText }); await navigator.clipboard.write([data]); } else { // Fallback: inject a hidden contenteditable, select, execCommand const div = document.createElement('div'); div.contentEditable = 'true'; div.style.position = 'fixed'; div.style.left = '-9999px'; div.style.top = '0'; div.innerHTML = html; document.body.appendChild(div); const range = document.createRange(); range.selectNodeContents(div); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); document.execCommand('copy'); document.body.removeChild(div); } DialogManager.showToast('Đã copy báo cáo (định dạng) vào clipboard!'); } catch (error) { console.error('Failed to copy rich report:', error); // Last resort fallback try { await navigator.clipboard.writeText(textFallback || ''); DialogManager.showToast('Đã copy báo cáo dạng text (fallback).'); } catch (e2) { DialogManager.showToast('Lỗi khi copy báo cáo', { background: '#d32f2f', duration: 3000 }); } } } /** * Fetch patient data from ToDieuTri API */ async function fetchToDieuTriData() { return ApiService.fetchToDieuTriData(); } /** * Add global styles for the dashboard */ function addGlobalStyles() { if (document.getElementById('dr-global-style')) return; const style = document.createElement('style'); style.id = 'dr-global-style'; style.textContent = ` /* Sidebar action buttons polish */ .dr-sidebar-actions { gap: 10px !important; padding: 6px 0 4px 0; } .dr-sidebar-actions .dr-detail-btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 12px; border: 1px solid #cbd5e1; background: #ffffff; color: #0f172a; font-weight: 600; line-height: 1; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); transition: all 0.18s ease; } .dr-sidebar-actions .dr-detail-btn svg { width: 18px; height: 18px; } .dr-sidebar-actions .dr-detail-btn img { width: 18px; height: 18px; object-fit: contain; display: block; } .dr-sidebar-actions .dr-detail-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 10px rgba(15, 23, 42, 0.12); border-color: #94a3b8; } .dr-sidebar-actions .dr-detail-btn:active { transform: translateY(0); box-shadow: 0 2px 6px rgba(15, 23, 42, 0.10); } .dr-sidebar-actions .dr-detail-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.35); } .dr-sidebar-actions .dr-detail-btn:first-child { background: linear-gradient(180deg, #1e88e5, #1976d2); color: #fff; border-color: #1976d2; } .dr-sidebar-actions .dr-detail-btn:first-child:hover { filter: brightness(1.03); box-shadow: 0 6px 14px rgba(25, 118, 210, 0.25); } .dr-sidebar-actions .dr-detail-btn:last-child { background: #ffffff; color: #0f172a; border-color: #cbd5e1; } .dr-sidebar-actions .dr-detail-btn:last-child:hover { background: #f8fafc; } /* Quick y lệnh actions */ .quick-ylenh-actions { display:flex; flex-wrap:wrap; gap:8px; margin:10px 0; padding:10px; background:#f8f9fa; border-radius:8px; border:1px solid #e9ecef; } .quick-ylenh-btn { display:flex; align-items:center; gap:6px; padding:8px 12px; border:none; border-radius:6px; background:#fff; color:#333; font-size:12px; font-weight:500; cursor:pointer; transition:all 0.2s ease; border:2px solid transparent; white-space:nowrap; position:relative; } .quick-ylenh-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); border-color: currentColor; } .quick-ylenh-btn:active { transform: translateY(0); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .quick-ylenh-btn.active { border: 3px solid #d32f2f !important; background-color: #ffebee; box-shadow: 0 0 10px rgba(211, 47, 47, 0.3); } .quick-ylenh-btn.active::after { content: '⏳'; position:absolute; top:-6px; right:-6px; background:#1d4ed8; color:#fff; width:18px; height:18px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:12px; } .quick-ylenh-btn.done { border: 3px solid #2e7d32 !important; background-color: #e8f5e9; color: #1b5e20 !important; box-shadow: 0 0 10px rgba(27, 94, 32, 0.2); position: relative; } .quick-ylenh-btn.done::after { content: '✔'; position:absolute; top:-6px; right:-6px; background:#2e7d32; color:#fff; width:18px; height:18px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:12px; } .xv-time-editor { display:flex; align-items:center; gap:8px; padding:8px 12px; margin:6px 0 0 0; background:#f1f5f9; border:1px dashed #cbd5e1; border-radius:8px; width:fit-content; } .xv-time-editor .xv-label { color:#0f172a; font-weight:600; } .xv-time-editor .xv-time { padding:4px 6px; border:1px solid #cbd5e1; border-radius:6px; } .xv-time-editor .xv-saved { color:#16a34a; font-weight:600; } /* Tags (generic) */ .ylenh-tags { display:flex; flex-wrap:wrap; gap:4px; margin:8px 0 4px 0; overflow-wrap:anywhere; word-break:break-word; } .ylenh-tag { display:inline-flex; align-items:center; gap:4px; padding:4px 10px; background-color: rgba(76, 175, 80, 0.1); color:#2e7d32; border:1px solid rgba(76, 175, 80, 0.3); border-radius:12px; font-size:12px; font-weight:600; line-height:1.2; white-space:normal; overflow-wrap:anywhere; word-break:break-word; max-width:100%; flex-wrap:wrap; } .ylenh-tag.discharge { background: linear-gradient(45deg, #4caf50, #66bb6a) !important; color: #fff !important; border: 2px solid #4caf50 !important; font-weight:700 !important; font-size:12px; text-shadow: 0 1px 1px rgba(0,0,0,0.25); } .ylenh-tag.completed { background-color: rgba(76, 175, 80, 0.2); color: #1b5e20; border-color: rgba(76, 175, 80, 0.5); } .ylenh-tag.state-active { background-color: rgba(37, 99, 235, 0.10); color:#1d4ed8; border-color: rgba(37, 99, 235, 0.35); font-weight:700; } .ylenh-tag.state-done { background-color: rgba(34, 197, 94, 0.12); color:#15803d; border-color: rgba(34, 197, 94, 0.45); font-weight:600; } .ylenh-tag.discharge.state-active { font-size:12.5px; font-weight:700; color:#ffffff !important; text-shadow: 0 1px 1px rgba(0,0,0,0.35); border-color:#2e7d32 !important; padding:4px 9px; } /* Card meds-done badge */ .dr-card .dr-badge-meds-done { position:absolute; top:-10px; right:10px; background:#16a34a; color:#fff; font-weight:800; font-size:11px; border-radius:999px; padding:4px 8px; box-shadow:0 2px 6px rgba(22,163,74,0.35); display:inline-flex; align-items:center; gap:6px; z-index:2; } .dr-card .dr-badge-meds-done::before { content:'✔'; background: rgba(255,255,255,0.2); width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; border-radius:50%; font-size:11px; } .dr-card.meds-done { border: 2px solid #16a34a !important; box-shadow: 0 0 0 2px rgba(22,163,74,0.08), 0 4px 12px rgba(0,0,0,0.06); } .dr-card-list { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; padding: 30px; /* Ensure content is not hidden behind fixed bottom bar */ padding-bottom: 90px; } /* List view container and rows */ .dr-list-container { display: grid; grid-template-columns: 1fr; gap: 10px; padding: 10px 12px 90px 12px; /* keep room for bottom bar */ } @media (min-width: 1200px) { .dr-list-container { grid-template-columns: 1fr 1fr; /* 2 columns on large screens */ } } .dr-list-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 12px 10px 12px; /* extra top space for badge */ background: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04); cursor: pointer; min-height: 60px; position: relative; /* anchor for corner badges */ } .dr-list-row:hover { box-shadow: 0 4px 10px rgba(15, 23, 42, 0.10); border-color: #cbd5e1; } .dr-list-title { font-weight: 700; color: #0f172a; line-height: 1.2; margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dr-list-title .dr-list-dem { color:#64748b; font-weight:600; } .dr-list-title .dr-list-mabn { color:#334155; font-weight:700; } .dr-list-title .dr-list-loc { color:#64748b; } .dr-list-sub { color: #64748b; font-weight: 600; font-size: 12px; margin-bottom: 4px; } .dr-list-dx { color: #0f172a; font-size: 13px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; max-height: 2.8em; } /* Compact tags inside list rows */ .dr-list-row .ylenh-tags { margin: 6px 0 0 0; gap: 4px; } .dr-list-row .ylenh-tag { padding: 2px 8px; border-radius: 10px; font-size: 11px; line-height: 1.15; } .dr-list-actions { display: flex; align-items: center; gap: 8px; margin-left: 10px; flex-shrink: 0; position: relative; /* anchor for inline badge */ } .dr-btn-icon { width: 34px; height: 34px; border-radius: 10px; border: 1px solid #cbd5e1; background: linear-gradient(180deg, #1e88e5, #1976d2); box-shadow: 0 1px 2px rgba(25, 118, 210, 0.15); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease; } .dr-btn-icon:hover { transform: translateY(-1px); filter: brightness(1.03); box-shadow: 0 4px 10px rgba(25,118,210,0.22); } .dr-btn-icon:active { transform: translateY(0); box-shadow: 0 2px 6px rgba(25,118,210,0.18); } .dr-btn-icon svg { width: 16px; height: 16px; } .dr-badge-meds-inline { background: #16a34a; color: #fff; font-weight: 700; font-size: 11px; border-radius: 999px; padding: 2px 8px; line-height: 1.2; box-shadow: 0 1px 2px rgba(22,163,74,0.2); white-space: nowrap; position: absolute; top: -8px; right: -6px; pointer-events: none; } .dr-badge-meds-row-corner { position: absolute; top: -8px; left: -6px; background: #16a34a; color: #fff; font-weight: 800; font-size: 10px; border-radius: 999px; padding: 3px 8px; line-height: 1; box-shadow: 0 1px 3px rgba(22,163,74,0.25); pointer-events: none; z-index: 2; } /* Unify HXT typography */ .dr-hxt-block { color: #0f172a; font-size: 13px; line-height: 1.35; } .dr-hxt-block .dr-label { color: #0f172a; font-weight: 700; } @media (max-width: 600px) { .dr-list-row { padding: 12px 10px 8px 10px; gap: 10px; } .dr-list-title { font-size: 14px; } .dr-list-sub { font-size: 11px; } .dr-list-dx { font-size: 12px; -webkit-line-clamp: 2; } .dr-btn-icon { width: 30px; height: 30px; border-radius: 8px; } .dr-btn-icon svg { width: 14px; height: 14px; } .dr-sidebar-actions { gap: 6px !important; } .dr-sidebar-actions .dr-detail-btn { gap:6px; padding:8px 10px; border-radius:10px; font-size:12px; line-height:1.1; } .dr-sidebar-actions .dr-detail-btn svg, .dr-sidebar-actions .dr-detail-btn img { width:14px; height:14px; } } .dr-card { background: #ffffff; border-radius: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.10); padding: 24px 20px 50px 20px; min-width: 260px; max-width: 320px; flex: 1 1 260px; display: flex; flex-direction: column; align-items: flex-start; position: relative; border: 2px solid #e3e3e3; cursor: pointer; } .dr-card.dr-blue { background: #e3f2fd; border: 2px solid #90caf9; } .dr-card h2 { margin: 0 0 8px 0; font-size: 1.2em; color: #1976d2; } .dr-card .dr-label { font-weight: bold; color: #000; } .dr-card .dr-value { margin-bottom: 6px; } /* Clamp secondary diagnosis (CD kèm theo) to 2 lines in card view */ .dr-card .dr-diagnosis-line .dr-cdkt-clamp { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; max-height: 2.8em; /* approx two lines */ } .dr-card .dr-detail-btn { position: absolute; right: 16px; bottom: 12px; background: #1976d2; color: #fff; border: none; border-radius: 50px; padding: 6px 16px 6px 10px; font-size: 15px; cursor: pointer; display: flex; align-items: center; box-shadow: 0 2px 6px rgba(25,118,210,0.10); white-space: nowrap; } .dr-card .dr-detail-btn svg { margin-right: 4px; width: 16px; height: 16px; } .dr-total { text-align: center; font-size: 1.1em; margin-top: 30px; color: #1976d2; font-weight: bold; } .dr-nodata, .dr-login { text-align: center; font-size: 1.2em; color: #b71c1c; margin-top: 40px; } .dr-bottom-bar { position: fixed; left: 0; right: 0; bottom: 0; width: 100vw; background: #fff; border-top: 2px solid #90caf9; box-shadow: 0 -2px 8px rgba(25,118,210,0.08); display: flex; justify-content: space-between; align-items: center; padding: 0 24px; height: 54px; z-index: 99999; font-size: 1.1em; } /* Bottom bar left cluster */ .dr-bottom-bar-left { display:flex; align-items:center; gap: 10px; } /* Khoa select (pretty) */ .dr-khoa-select { height: 34px; min-width: 200px; padding: 0 34px 0 10px; /* room for chevron */ border: 1px solid #cbd5e1; border-radius: 8px; background: #ffffff; color: #0f172a; font-size: 14px; font-weight: 600; box-shadow: 0 1px 2px rgba(15,23,42,0.05); transition: border-color .15s ease, box-shadow .15s ease, filter .15s ease; -webkit-appearance: none; appearance: none; background-image: url("data:image/svg+xml;utf8,\ <svg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'>\ <path d='M6 8l4 4 4-4' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/>\ </svg>"); background-repeat: no-repeat; background-position: right 8px center; background-size: 18px 18px; cursor: pointer; } .dr-khoa-select:hover { border-color: #94a3b8; filter: brightness(1.02); } .dr-khoa-select:focus { outline: none; border-color: #60a5fa; box-shadow: 0 0 0 3px rgba(59,130,246,0.25); } .dr-khoa-select:disabled { opacity: .6; cursor: not-allowed; } /* settings “gear” anchor next to select */ .dr-gear-btn { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:50%; color:#1976d2; border:1px solid rgba(25,118,210,0.25); text-decoration:none; background:#fff; transition: box-shadow .15s ease, background .15s ease; } .dr-gear-btn i { font-size:16px; } .dr-gear-btn:hover { background:#e3f2fd; box-shadow:0 0 0 2px rgba(25,118,210,0.15) inset; } /* Offline banner */ .dr-offline-banner { background:#fff3cd; color:#8a6d3b; border:1px solid #ffeeba; padding:6px 10px; border-radius:6px; margin:8px 0; display:none; } .dr-bottom-bar-left { color: #1976d2; font-weight: bold; } @media (max-width: 600px) { .dr-bottom-bar { flex-direction: column; height: auto; padding: 8px 8px; } .dr-card-list { flex-direction: column; align-items: center; } /* Card action buttons: smaller on phones */ .dr-card .dr-detail-btn { padding: 6px 10px 6px 8px; font-size: 12px; border-radius: 16px; } .dr-card .dr-detail-btn svg { width: 14px; height: 14px; margin-right: 4px; } /* Action group spacing and positioning */ .dr-action-buttons { gap: 6px !important; right: 10px !important; bottom: 8px !important; } /* Icon-only copy button (inline style width/height) shrink */ .dr-action-buttons .dr-detail-btn[title="Copy báo cáo (1 BN)"] { width: 30px !important; height: 30px !important; padding: 6px !important; border-radius: 8px !important; } .dr-action-buttons .dr-detail-btn[title="Copy báo cáo (1 BN)"] svg { width: 14px; height: 14px; } } #dr-sidebar-backdrop { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.25); z-index: 99999; } #dr-sidebar { position: fixed; top: 0; right: 0; width: 80vw; max-width: 80vw; height: 100vh; background: #fff; z-index: 100000; box-shadow: -2px 0 16px rgba(0,0,0,0.15); padding: 32px 24px 24px 24px; overflow-y: auto; transition: right 0.2s; } /* Sidebar responsive layout */ @media (min-width: 1024px) { .dr-sidebar-container { flex-direction: row !important; gap: 24px !important; } .dr-sidebar-left { flex: 0 0 40% !important; } .dr-sidebar-right { flex: 1 !important; } } /* Xuất viện animation class - Hiệu ứng ngôi sao */ .dr-card.xuatvienanimation { position: relative; overflow: hidden; border: 3px solid #ffd700 !important; background: linear-gradient(135deg, #fff9c4, #ffffff) !important; animation: starGlow 3s ease-in-out infinite; } /* Xuất viện animation cho card blue - border blue glow */ .dr-card.xuatvienanimation.dr-blue { border: 3px solid #2196f3 !important; background: linear-gradient(135deg, #e3f2fd, #ffffff) !important; animation: starGlowBlue 3s ease-in-out infinite; } .dr-card.xuatvienanimation::before { content: '⭐'; position: absolute; top: 10px; right: 10px; font-size: 24px; animation: starRotate 2s linear infinite; z-index: 10; } .dr-card.xuatvienanimation::after { content: '✨ 🎉 ✨'; position: absolute; top: -5px; left: 50%; transform: translateX(-50%); font-size: 14px; animation: sparkle 1.5s ease-in-out infinite; z-index: 10; } @keyframes starGlow { 0%, 100% { box-shadow: 0 2px 12px rgba(0,0,0,0.10), 0 0 20px rgba(255, 215, 0, 0.4); } 50% { box-shadow: 0 2px 12px rgba(0,0,0,0.10), 0 0 30px rgba(255, 215, 0, 0.8); } } @keyframes starGlowBlue { 0%, 100% { box-shadow: 0 2px 12px rgba(0,0,0,0.10), 0 0 20px rgba(33, 150, 243, 0.4); } 50% { box-shadow: 0 2px 12px rgba(0,0,0,0.10), 0 0 30px rgba(33, 150, 243, 0.8); } } @keyframes starRotate { 0% { transform: rotate(0deg) scale(1); } 25% { transform: rotate(90deg) scale(1.2); } 50% { transform: rotate(180deg) scale(1); } 75% { transform: rotate(270deg) scale(1.2); } 100% { transform: rotate(360deg) scale(1); } } @keyframes sparkle { 0%, 100% { opacity: 0.6; transform: translateX(-50%) translateY(0px); } 50% { opacity: 1; transform: translateX(-50%) translateY(-5px); } } @media print { .no-print { display: none !important; } /* White cards (214, 215, 216) - giữ màu trắng khi in */ .dr-card:not(.dr-blue) { background: #0d8ae3ff !important; border: 2px solid #c4490bff !important; color: #000 !important; } /* Blue cards (các phòng khác) - giữ background blue khi in */ .dr-card.dr-blue { background: #e3f2fd !important; border: 2px solid #2196f3 !important; color: #000 !important; } .dr-card h2 { color: #000 !important; } .dr-bottom-bar { display: none !important; } /* Tắt animation khi in */ .dr-card.xuatvienanimation, .dr-card.xuatvienanimation.dr-blue { animation: none !important; border: 2px solid #ccc !important; background: #fff !important; } .dr-card.xuatvienanimation::before, .dr-card.xuatvienanimation.dr-blue::before { display: inline-flex; align-items:center; gap:10px; display: none !important; .dr-khoa-select { height: 32px; min-width: 180px; border:1px solid #cbd5e1; border-radius: 8px; padding: 0 8px; } .dr-gear-btn { display:inline-flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:50%; color:#1976d2; border:1px solid rgba(25,118,210,0.25); text-decoration:none; background:#fff; } .dr-gear-btn i { font-size:16px; } .dr-gear-btn:hover { background:#e3f2fd; box-shadow:0 0 0 2px rgba(25,118,210,0.15) inset; } } .dr-card.xuatvienanimation::after, .dr-card.xuatvienanimation.dr-blue::after { display: none !important; } } `; document.head.appendChild(style); } /** * Update checklist data (wrapper for backward compatibility) */ function updateChecklistPhieu(oldData, checklistState, callback) { ApiService.updateChecklistData(oldData, checklistState) .then(result => { if (typeof callback === 'function') { callback(result); } }) .catch(error => { console.error('Failed to update checklist:', error); if (typeof callback === 'function') { callback(null); } }); } /** * Create checklist for patient (wrapper for backward compatibility) */ function createChecklistPhieu(patient, callback) { ApiService.createChecklistForPatient(patient) .then(result => { if (typeof callback === 'function') { callback(result); } }) .catch(error => { console.error('Failed to create checklist:', error); if (typeof callback === 'function') { callback(null); } }); } module.exports = { createDirectReportGeneration, fetchToDieuTriData, addGlobalStyles, copyReportToClipboardRich, updateChecklistPhieu, createChecklistPhieu }; },{"../components/dialogManager":7,"../services/apiService":26,"../services/reportService":29,"../utils/dateUtils":36}],22:[function(require,module,exports){ // page.lichmo.homnay.js - Lịch mổ hôm nay (today's surgery schedule) const { showToast } = require('../utils/uiUtils'); const { getSelectedKhoa } = require('../utils/khoaUtils'); const ApiService = require('../services/apiService'); const SurgeonSettingsService = require('../services/surgeonSettingsService'); const { createKhoaSelect } = require('../components/khoaSelect'); function stylesOnce() { if (document.getElementById('dr-qh-lichmo-css')) return; const st = document.createElement('style'); st.id = 'dr-qh-lichmo-css'; st.textContent = ` .dr-qh-lichmo-wrap{min-height:100vh;background:#fff;color:#0f172a;font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif} .dr-qh-lichmo-head{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid #e5e7eb;position:sticky;top:0;background:#fff;z-index:10;gap:12px} .dr-qh-lichmo-left{display:flex;align-items:center;gap:10px} .dr-qh-lichmo-title{margin:0;font-size:18px} .dr-qh-lichmo-khoa{color:#475569;font-size:13px;background:#f1f5f9;border-radius:8px;padding:3px 8px} .dr-qh-lichmo-mid{display:flex;align-items:center;gap:6px} .dr-qh-lichmo-date{font-weight:600} .dr-qh-lichmo-btn{border:1px solid #e5e7eb;background:#fff;border-radius:8px;padding:6px 10px;cursor:pointer} .dr-qh-lichmo-btn:hover{background:#f8fafc} .dr-qh-lichmo-right{display:flex;align-items:center;gap:8px} .dr-qh-lichmo-status{color:#64748b;font-size:13px} .dr-qh-lichmo-loading{width:16px;height:16px;border:2px solid #94a3b8;border-top-color:#0ea5e9;border-radius:50%;animation:drspin 1s linear infinite;display:none} .dr-qh-lichmo-loading.active{display:inline-block} @keyframes drspin{to{transform:rotate(360deg)}} .dr-qh-lichmo-content{padding:16px 18px;height:calc(100vh - 58px);overflow:auto;overscroll-behavior:contain} /* Timeline container */ .dr-qh-timeline{position:relative;border-left:1px dashed #e2e8f0;padding-left:12px} .dr-qh-timegrid{position:relative;min-height:720px;background:linear-gradient(180deg,#fff 0,#fff 49%,#f8fafc 50%,#f8fafc 100%);background-size:100% 60px;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden} .dr-qh-timeaxis{position:absolute;left:0;top:0;width:54px;border-right:1px solid #e5e7eb;background:#fff;z-index:2} .dr-qh-timeaxis .tick{position:absolute;left:0;right:0;height:1px;background:#e5e7eb} .dr-qh-timeaxis .label{position:absolute;left:6px;transform:translateY(-50%);font-size:12px;color:#64748b;background:#fff;padding:0 2px} .dr-qh-lanes{position:absolute;left:54px;right:0;top:0} .dr-qh-lane{position:relative} .dr-qh-evtbar{position:absolute;left:8px;right:12px;border-radius:16px;display:flex;flex-direction:column;align-items:flex-start;padding:14px 18px;color:#0f172a;box-shadow:0 8px 20px rgba(2,6,23,.12);border:1px solid rgba(15,23,42,.08);min-height:80px;transition:all .2s ease;overflow:hidden} .dr-qh-evtbar:hover{box-shadow:0 12px 30px rgba(2,6,23,.18);transform:translateY(-1px)} .dr-qh-evtbar .row{display:flex;gap:8px;align-items:flex-start;min-width:0;width:100%;line-height:1.4;margin-bottom:6px} .dr-qh-evtbar .row:last-child{margin-bottom:0} .dr-qh-evtbar .time{font-size:14px;font-weight:600;color:#1976d2;white-space:nowrap;margin-left:auto} .dr-qh-evtbar .patient{font-weight:800;font-size:18px;text-transform:uppercase;letter-spacing:.3px;color:#1976d2;line-height:1.2;word-wrap:break-word;overflow-wrap:break-word;flex:1} .dr-qh-evtbar .method{font-size:14px;color:#374151;font-weight:500;line-height:1.3;word-wrap:break-word;overflow-wrap:break-word;flex:1;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} .dr-qh-evtbar .docs{font-size:14px;color:#7c3aed;font-weight:500;line-height:1.3;word-wrap:break-word;overflow-wrap:break-word;flex:1} /* Emphasize OR row: largest and bold */ .dr-qh-evtbar .or{font-size:16px;font-weight:800;color:#1976d2;background:#fef3c7;padding:4px 10px;border-radius:8px;border:1px solid #fbbf24;margin-bottom:4px;word-wrap:break-word;overflow-wrap:break-word;flex:1} /* Make meta as prominent as patient */ .dr-qh-evtbar .meta{font-size:14px;font-weight:600;color:#1f2937;background:#f3f4f6;padding:3px 8px;border-radius:6px;border:1px solid #d1d5db;word-wrap:break-word;overflow-wrap:break-word;flex:1} .dr-qh-evtbar .diagnose{font-size:14px;color:#374151;font-weight:500;line-height:1.3;word-wrap:break-word;overflow-wrap:break-word;flex:1;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} .dr-qh-evtbar.tight{padding:8px 12px;min-height:80px} .dr-qh-evtbar.tight .method{font-size:13px} .dr-qh-evtbar.tight .docs{font-size:12px} .dr-qh-empty{padding:12px;border:1px dashed #cbd5e1;border-radius:10px;color:#64748b;background:#f8fafc} @media (max-width: 1100px){.dr-qh-timeaxis{width:46px}.dr-qh-lanes{left:46px}} `; document.head.appendChild(st); } function subscribeOTMMessages(onSuccess, onProgress, onError) { try { if (typeof GM !== 'undefined' && GM.addValueChangeListener) { const unsubIds = []; unsubIds.push(GM.addValueChangeListener('otm_success', (n, o, v) => { try { const parsed = typeof v === 'string' ? JSON.parse(v) : v; const data = parsed && parsed.data; onSuccess && onSuccess(data); } catch(_) {} })); unsubIds.push(GM.addValueChangeListener('otm_progress', (n, o, v) => { try { const parsed = typeof v === 'string' ? JSON.parse(v) : v; const data = parsed && parsed.data; onProgress && onProgress(data); } catch(_) {} })); unsubIds.push(GM.addValueChangeListener('otm_error', (n, o, v) => { try { const parsed = typeof v === 'string' ? JSON.parse(v) : v; const data = parsed && parsed.data; onError && onError(data); } catch(_) {} })); // Listen for close request to ensure background tab is closed if self-close failed unsubIds.push(GM.addValueChangeListener('otm_close_tab', (n, o, v) => { try { closeOTMTabIfAny(); } catch(_) {} scheduleCloseAfterSignal(5000); })); return () => { try { unsubIds.forEach(id => { try { GM.removeValueChangeListener && GM.removeValueChangeListener(id); } catch(_) {} }); } catch(_) {} }; } } catch(_) {} // Fallback localStorage polling const tid = setInterval(() => { try { const s = localStorage.getItem('otm_success'); if (s) { localStorage.removeItem('otm_success'); const p = JSON.parse(s); onSuccess && onSuccess(p && p.data); } const pr = localStorage.getItem('otm_progress'); if (pr) { localStorage.removeItem('otm_progress'); const p = JSON.parse(pr); onProgress && onProgress(p && p.data); } const er = localStorage.getItem('otm_error'); if (er) { localStorage.removeItem('otm_error'); const p = JSON.parse(er); onError && onError(p && p.data); } const ct = localStorage.getItem('otm_close_tab'); if (ct) { localStorage.removeItem('otm_close_tab'); try { closeOTMTabIfAny(); } catch(_) {} scheduleCloseAfterSignal(5000); } } catch(_) {} }, 800); return () => clearInterval(tid); } let lastOTMTab = null; let pendingCloseTimer = null; let pendingCloseDeadline = 0; function closeOTMTabIfAny() { try { if (lastOTMTab && !lastOTMTab.closed) { lastOTMTab.close(); } } catch(_) {} lastOTMTab = null; } function scheduleCloseAfterSignal(ms = 5000) { try { if (pendingCloseTimer) { clearInterval(pendingCloseTimer); } } catch(_) {} pendingCloseDeadline = Date.now() + Math.max(1000, ms); pendingCloseTimer = setInterval(() => { try { closeOTMTabIfAny(); } catch(_) {} if (!lastOTMTab || lastOTMTab.closed || Date.now() > pendingCloseDeadline) { try { clearInterval(pendingCloseTimer); } catch(_) {} pendingCloseTimer = null; pendingCloseDeadline = 0; } }, 250); } function openOTMSurgeriesTab(fromDate, toDate) { // Avoid tab accumulation: close previous background tab if still open try { closeOTMTabIfAny(); } catch(_) {} const payload = encodeURIComponent(JSON.stringify({ fromDate, toDate, preferToken: true })); const url = `https://otm.tahospital.vn/?otm-fetch=${payload}`; if (typeof GM !== 'undefined' && GM.openInTab) { try { const ret = GM.openInTab(url, { active: false, insert: true, setParent: true }); // Tampermonkey may return a Tab or a Promise<Tab> if (ret && typeof ret.then === 'function') { ret.then(h => { try { if (h && h.close) { lastOTMTab = h; } } catch(_) {} }); } else { lastOTMTab = ret && ret.close ? ret : null; } return; } catch(_) {} } try { lastOTMTab = window.open(url, '_blank'); } catch(_) { /* ignore */ } } function formatTimeRange(start, end) { try { const s = start ? new Date(start) : null; const e = end ? new Date(end) : null; const fmt = (d)=> d ? d.toLocaleTimeString('vi-VN', {hour:'2-digit', minute:'2-digit'}) : ''; const sStr = fmt(s); const eStr = fmt(e); return sStr + (eStr ? ` - ${eStr}` : ''); } catch(_) { return start || ''; } } function filterBySelectedSurgeons(surgeries, selectedNames) { if (!Array.isArray(surgeries) || surgeries.length === 0) return []; const set = new Set((selectedNames || []).map(s => (s || '').toString().trim().toLowerCase()).filter(Boolean)); if (set.size === 0) return surgeries; // If nothing selected, show all return surgeries.filter(item => { const all = [ ...(Array.isArray(item.userexec) ? item.userexec : []), ...(Array.isArray(item.userassistant) ? item.userassistant : []) ]; return all.some(u => set.has(((u && (u.fullname || u.name)) || '').toString().trim().toLowerCase())); }); } function sortByStart(a, b) { const as = a && a.start ? Date.parse(a.start) : 0; const bs = b && b.start ? Date.parse(b.start) : 0; return as - bs; } function renderTimeAxisHTML(startHour, endHour, timelineHeight = 720) { let ticksHTML = ''; for (let h = startHour; h <= endHour; h++) { const y = (h - startHour) * (timelineHeight / (endHour - startHour)); ticksHTML += ` <div class="tick" style="top: ${y}px;"></div> <div class="label" style="top: ${y}px;">${String(h).padStart(2,'0')}:00</div> `; } return `<div class="dr-qh-timeaxis" style="height: ${timelineHeight}px;">${ticksHTML}</div>`; } function calculateContentHeight(item) { const { s } = item; // Count content rows let rowCount = 0; // Operating room + time row const opRoom = s.operating_room || (s.room && s.room.name) || ''; if (opRoom) rowCount++; // Patient name row (always present) rowCount++; // Meta info row const pid = (s.customer && (s.customer.pid || s.customer.code || '')) || ''; const phong = s.phongDieuTri || ''; const giuong = s.giuongDieuTri || ''; if (pid || phong || giuong) rowCount++; // Diagnosis row (now before surgery method) const diagnose = s.diagnose || ''; if (diagnose) { // Count lines for diagnosis (might wrap to 2 lines) const diagnosisLineCount = Math.min(2, Math.ceil(diagnose.length / 50)); rowCount += diagnosisLineCount; } // Surgery method row const method = s.surgerymethod || ''; if (method) { // Count lines for method (might wrap to 2 lines) const methodLineCount = Math.min(2, Math.ceil(method.length / 50)); rowCount += methodLineCount; } // Doctors row const exec = (s.userexec||[]).map(u => u && (u.fullname || u.name)).filter(Boolean); const assistant = (s.userassistant||[]).map(u => u && (u.fullname || u.name)).filter(Boolean); if (exec.length || assistant.length) rowCount++; // Calculate height: base padding + row height * count + gaps const basePadding = 28; // 14px top + 14px bottom const rowHeight = 24; // estimated height per row including line-height const gapHeight = 6; // gap between rows const calculatedHeight = basePadding + (rowHeight * rowCount) + (gapHeight * Math.max(0, rowCount - 1)); return Math.max(80, calculatedHeight); // minimum 80px } function renderSurgeryBarHTML(item, pxPerMin, palette) { const { s, idx, topMin, height } = item; // Extract data const patient = (s.customer && (s.customer.fullname || '')) || ''; const method = s.surgerymethod || ''; const exec = (s.userexec||[]).map(u => u && (u.fullname || u.name)).filter(Boolean).join(', '); const assistant = (s.userassistant||[]).map(u => u && (u.fullname || u.name)).filter(Boolean).join(', '); const pid = (s.customer && (s.customer.pid || s.customer.code || '')) || ''; const phong = s.phongDieuTri || ''; const giuong = s.giuongDieuTri || ''; const opRoom = s.operating_room || (s.room && s.room.name) || ''; const timeRange = formatTimeRange(s.start, s.end) || '—'; const diagnose = s.diagnose || ''; // Create tooltip const tooltip = [ `Phòng mổ: ${opRoom}`, `Bệnh nhân: ${patient}`, `PID: ${pid}`, phong ? `Phòng: ${phong}` : '', giuong ? `Giường: ${giuong}` : '', diagnose ? `Chẩn đoán: ${diagnose}` : '', `PPPT: ${method}`, exec ? `BS chính: ${exec}` : '', assistant ? `BS phụ: ${assistant}` : '', s.status ? `Ghi chú: ${s.status}` : '' ].filter(Boolean).join('\n'); // Meta info const metaInfo = [ pid ? `PID: ${pid}` : '', phong ? `Phòng: ${phong}` : '', giuong ? `Giường: ${giuong}` : '' ].filter(Boolean).join(' • '); const docsInfo = [exec, assistant ? `(phụ: ${assistant})` : ''].filter(Boolean).join(' '); // Style properties with content-based height const contentHeight = calculateContentHeight(item); const displayHeight = Math.max(contentHeight, height * pxPerMin); // Use larger of content or time-based height const barStyle = ` top: ${topMin * pxPerMin}px; height: ${displayHeight}px; background: ${palette[idx % palette.length]}; `; const tightClass = displayHeight < 100 ? ' tight' : ''; return ` <div class="dr-qh-evtbar${tightClass}" style="${barStyle}" title="${tooltip}"> ${opRoom ? ` <div class="row"> <span class="or">Phòng mổ: ${opRoom}</span> <span class="time">${timeRange}</span> </div> ` : ''} <div class="row"> <span class="patient">${patient}</span> </div> ${metaInfo ? ` <div class="row"> <span class="meta">${metaInfo}</span> </div> ` : ''} ${diagnose ? ` <div class="row"> <span class="diagnose"><b>CĐ:</b> ${diagnose}</span> </div> ` : ''} ${method ? ` <div class="row"> <span class="method"><b>PPPT:</b> ${method}</span> </div> ` : ''} ${docsInfo ? ` <div class="row"> <span class="docs">${docsInfo}</span> </div> ` : ''} </div> `; } function renderUI(container, dateStr, surgeries) { if (!surgeries || surgeries.length === 0) { container.innerHTML = '<div class="dr-qh-empty">Không có ca mổ nào trong ngày này.</div>'; return; } // Prepare timeline range const startHour = 7, endHour = 24; const totalMinutes = (endHour - startHour) * 60; const pxPerMin = 720 / totalMinutes; // Compute bars with overlap lanes and content-based heights const items = surgeries.map((s, idx) => { const sMs = s && s.start ? Date.parse(s.start) : NaN; const eMs = s && s.end ? Date.parse(s.end) : NaN; const sD = isNaN(sMs) ? null : new Date(sMs); const eD = isNaN(eMs) ? null : new Date(eMs); const clamp = (d) => Math.max(0, Math.min(totalMinutes - 5, (d.getHours() - startHour) * 60 + d.getMinutes())); const topMin = sD ? clamp(sD) : 0; const endMin = eD ? clamp(eD) : (topMin + 60); const timeBasedHeight = Math.max(60, endMin - topMin); // Reduced minimum for time-based return { s, idx, topMin, height: timeBasedHeight }; }).sort((a,b) => a.topMin - b.topMin); // Calculate content heights and determine actual display heights items.forEach(item => { const contentHeight = calculateContentHeight(item); const timeHeight = item.height; item.displayHeight = Math.max(contentHeight, timeHeight); // Convert back to minutes for lane calculation item.heightInMinutes = item.displayHeight / pxPerMin; }); // Assign lanes using display heights for overlapping bars const lanes = []; items.forEach(it => { let placed = false; for (let i = 0; i < lanes.length; i++) { if (lanes[i] <= it.topMin - 4) { it.lane = i; lanes[i] = it.topMin + it.heightInMinutes; placed = true; break; } } if (!placed) { it.lane = lanes.length; lanes.push(it.topMin + it.heightInMinutes); } }); // Calculate required timeline height based on content const maxEndTime = Math.max(...items.map(item => item.topMin + item.heightInMinutes)); const requiredTimelineHeight = Math.max(720, maxEndTime * pxPerMin + 40); // 40px bottom padding // Generate lanes HTML const laneWidthPercent = 100 / Math.max(1, lanes.length); const palette = ['#e0f2fe','#f0f9ff','#fdf4ff','#fef7ed','#fef2f2','#fffbeb','#f0fdf4']; let lanesHTML = ''; for (let i = 0; i < lanes.length; i++) { const laneStyle = ` position: absolute; left: ${i * laneWidthPercent}%; width: ${laneWidthPercent}%; top: 0; height: ${requiredTimelineHeight}px; `; const barsInLane = items .filter(item => item.lane === i) .map(item => renderSurgeryBarHTML(item, pxPerMin, palette)) .join(''); lanesHTML += `<div class="dr-qh-lane" style="${laneStyle}">${barsInLane}</div>`; } // Render complete timeline with dynamic height const timelineHTML = ` <div class="dr-qh-timeline"> <div class="dr-qh-timegrid" style="height: ${requiredTimelineHeight}px;"> ${renderTimeAxisHTML(startHour, endHour, requiredTimelineHeight)} <div class="dr-qh-lanes" style="height: ${requiredTimelineHeight}px;">${lanesHTML}</div> </div> </div> `; container.innerHTML = timelineHTML; } async function showLichMoHomNayIfNeeded() { const url = new URL(window.location.href); const hasLm = /[?&]lm(=|&|$)/.test(url.search); const hasLichmo = /[?&]lichmo(=|&|$)/.test(url.search) || (url.searchParams.get('otm')||'').toLowerCase() === 'lichmo'; if (!hasLm && !hasLichmo) return; stylesOnce(); document.body.innerHTML = ''; const wrap = document.createElement('div'); wrap.className = 'dr-qh-lichmo-wrap'; const head = document.createElement('div'); head.className = 'dr-qh-lichmo-head'; head.innerHTML = ` <div class="dr-qh-lichmo-left"> <h3 class="dr-qh-lichmo-title">Lịch mổ hôm nay</h3> <span id="dr-qh-lichmo-khoa" class="dr-qh-lichmo-khoa"></span> </div> <div class="dr-qh-lichmo-mid"> <button id="dr-qh-lichmo-prev" class="dr-qh-lichmo-btn" title="Ngày trước">◀</button> <input id="dr-qh-lichmo-date" class="dr-qh-lichmo-date" type="date" /> <button id="dr-qh-lichmo-next" class="dr-qh-lichmo-btn" title="Ngày sau">▶</button> <button id="dr-qh-lichmo-today" class="dr-qh-lichmo-btn" title="Hôm nay">Hôm nay</button> </div> <div class="dr-qh-lichmo-right"> <div id="dr-qh-lichmo-loading" class="dr-qh-lichmo-loading" aria-label="Đang tải" title="Đang tải"></div> <div id="dr-qh-lichmo-status" class="dr-qh-lichmo-status">Chuẩn bị lấy dữ liệu...</div> <button id="dr-qh-lichmo-refresh" class="dr-qh-lichmo-btn">Làm mới</button> </div> `; const content = document.createElement('div'); content.className = 'dr-qh-lichmo-content'; wrap.appendChild(head); wrap.appendChild(content); document.body.appendChild(wrap); const statusEl = head.querySelector('#dr-qh-lichmo-status'); const loadingEl = head.querySelector('#dr-qh-lichmo-loading'); const dateEl = head.querySelector('#dr-qh-lichmo-date'); const prevBtn = head.querySelector('#dr-qh-lichmo-prev'); const nextBtn = head.querySelector('#dr-qh-lichmo-next'); const todayBtn = head.querySelector('#dr-qh-lichmo-today'); const refreshBtn = head.querySelector('#dr-qh-lichmo-refresh'); const khoaEl = head.querySelector('#dr-qh-lichmo-khoa'); // mount reusable khoa select at vị trí "dr-qh-lichmo-khoa" const khoaComp = createKhoaSelect({ container: khoaEl, onChange: async (newId, newName) => { khoaId = String(newId); await doRefresh(); scheduleAutoRefresh(); }}); // State let currentDate = new Date(); let refreshTimer = null; let selectedNames = []; let khoaId = getSelectedKhoa('551'); let khoaNameCache = ''; let isBusy = false; // single-flight lock let activeUnsub = null; let busyWatchdog = null; let closeRetryTimer = null; // no longer used; child self-closes function fmtVietnam(d) { const yyyy = d.getFullYear(); const mm = String(d.getMonth()+1).padStart(2,'0'); const dd = String(d.getDate()).padStart(2,'0'); return { iso: `${yyyy}-${mm}-${dd}`, human: `${dd}/${mm}/${yyyy}` }; } async function loadKhoaNameOnce() { try { const list = await ApiService.fetchKhoaPhong(); const found = (list||[]).find(k => String(k.id) === String(khoaId)); khoaNameCache = (found && found.name) || `Khoa ${khoaId}`; } catch(_) { khoaNameCache = `Khoa ${khoaId}`; } // Label is handled by component; keep cache for title/other use if needed } async function loadSelectedSurgeons() { try { const { list } = await SurgeonSettingsService.loadSurgeonList(khoaId); selectedNames = (list || []).map(x => typeof x === 'string' ? x : x.fullname).filter(Boolean); } catch(_) { selectedNames = []; } } function setLoading(flag, message) { if (flag) loadingEl.classList.add('active'); else loadingEl.classList.remove('active'); if (message) statusEl.textContent = message; } function disableControls(disabled) { [prevBtn, nextBtn, todayBtn, refreshBtn, dateEl].forEach(el => { try { el.disabled = !!disabled; el.classList.toggle('dr-disabled', !!disabled); } catch(_) {} }); } function subscribeAndFetch(dateIso) { if (isBusy) { showToast('Đang lấy dữ liệu, vui lòng đợi tác vụ hiện tại hoàn tất...', { background: '#0284c7' }); return; } isBusy = true; disableControls(true); setLoading(true, `Đang mở OTM để lấy lịch mổ ngày ${dateIso}...`); showToast(`Đang lấy dữ liệu ngày ${dateIso}...`); const unsub = subscribeOTMMessages((data) => { try { const arr = data && (data.surgeryData || []); const usable = Array.isArray(arr) && arr.length ? arr : (data && data.surgeryDataRaw) || []; const filtered = filterBySelectedSurgeons(usable, selectedNames); content.innerHTML = ''; renderUI(content, dateIso, filtered); statusEl.textContent = `Đã tải ${usable.length} ca, hiển thị ${filtered.length} ca (lọc theo bác sĩ).`; showToast('Đã lấy dữ liệu.', { background: '#16a34a' }); } finally { try { unsub && unsub(); } catch(_) {} setLoading(false); isBusy = false; activeUnsub = null; disableControls(false); } }, (prog) => { if (prog && prog.message) { statusEl.textContent = prog.message; } }, (err) => { statusEl.textContent = (err && err.message) ? err.message : 'Lỗi khi lấy dữ liệu lịch mổ.'; showToast('Lỗi khi lấy dữ liệu lịch mổ.', { background: '#ef4444' }); try { unsub && unsub(); } catch(_) {} setLoading(false); isBusy = false; activeUnsub = null; disableControls(false); }); activeUnsub = unsub; openOTMSurgeriesTab(dateIso, dateIso); // Watchdog to avoid stuck busy state if no response arrives if (busyWatchdog) { try { clearTimeout(busyWatchdog); } catch(_) {} } busyWatchdog = setTimeout(() => { if (isBusy) { showToast('Quá thời gian chờ OTM phản hồi. Tác vụ bị hủy.', { background: '#ef4444' }); try { activeUnsub && activeUnsub(); } catch(_) {} setLoading(false); isBusy = false; activeUnsub = null; disableControls(false); } }, 75000); } async function doRefresh() { const { iso } = fmtVietnam(currentDate); if (dateEl && dateEl.type === 'date') { dateEl.value = iso; } await loadSelectedSurgeons(); subscribeAndFetch(iso); } function scheduleAutoRefresh() { const ms = Math.max(60000, parseInt(localStorage.getItem('dr_qh_lichmo_refresh_ms') || '300000', 10)); if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } refreshTimer = setInterval(() => { doRefresh(); }, ms); } // Wire header controls prevBtn.addEventListener('click', () => { currentDate.setDate(currentDate.getDate()-1); doRefresh(); scheduleAutoRefresh(); }); nextBtn.addEventListener('click', () => { currentDate.setDate(currentDate.getDate()+1); doRefresh(); scheduleAutoRefresh(); }); todayBtn.addEventListener('click', () => { currentDate = new Date(); doRefresh(); scheduleAutoRefresh(); }); refreshBtn.addEventListener('click', () => { doRefresh(); scheduleAutoRefresh(); }); // Allow manual date selection dateEl.addEventListener('change', () => { const v = dateEl.value; // yyyy-mm-dd if (v && /^\d{4}-\d{2}-\d{2}$/.test(v)) { const [y,m,d] = v.split('-').map(n=>parseInt(n,10)); const nd = new Date(y, m-1, d); if (!isNaN(nd.getTime())) { currentDate = nd; doRefresh(); scheduleAutoRefresh(); } } }); // Initial await loadKhoaNameOnce(); await doRefresh(); scheduleAutoRefresh(); } module.exports = { showLichMoHomNayIfNeeded }; },{"../components/khoaSelect":9,"../services/apiService":26,"../services/surgeonSettingsService":32,"../utils/khoaUtils":39,"../utils/uiUtils":43}],23:[function(require,module,exports){ // settings-open-world.js - Open World settings (Thông tin khoa/phòng) const SettingsService = require('../services/settingsService'); const ApiService = require('../services/apiService'); function createStylesOnce() { if (document.getElementById('dr-openworld-styles')) return; const st = document.createElement('style'); st.id = 'dr-openworld-styles'; st.textContent = ` .dr-ow-wrap { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .dr-ow-card { border:1px solid #e5e7eb; border-radius: 10px; padding: 10px; background: #fff; } .dr-ow-title { margin: 0 0 8px 0; font-weight: 700; color: #0f172a; } .dr-ow-list { display: flex; flex-direction: column; gap: 8px; max-height: 52vh; overflow: auto; } .dr-ow-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; border:1px solid #e5e7eb; border-radius: 8px; padding: 8px 10px; cursor: pointer; } .dr-ow-item:hover { background: #f8fafc; } .dr-ow-item.active { border-color: #16a34a; box-shadow: 0 0 0 2px rgba(22,163,74,.15) inset; } .dr-ow-badge { background: #16a34a; color: #fff; border-radius: 10px; padding: 2px 6px; font-size: 12px; } .dr-ow-empty { color:#6b7280; font-style: italic; } @media (max-width: 900px) { .dr-ow-wrap { grid-template-columns: 1fr; } } `; document.head.appendChild(st); } function debounce(fn, delay = 400) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), delay); }; } async function fetchKhoaPhong() { return ApiService.fetchKhoaPhong(); } async function fetchRoomsByKhoa(khoaId) { return ApiService.fetchRoomsByKhoa(khoaId); } /** * Mount Open World settings tab */ async function mountOpenWorldTab(opts) { const { container, doctorName, checklistObj, settings } = opts || {}; if (!container) return; createStylesOnce(); container.innerHTML = ` <div style="margin:0 0 8px; color:#6b7280">Quản lý khoa mặc định và các phòng theo dõi bệnh nhân. Việc chọn khoa sẽ được lưu và áp dụng ở dashboard.</div> <div class="dr-ow-wrap"> <div class="dr-ow-card"> <h4 class="dr-ow-title">Danh sách khoa/phòng</h4> <div id="dr-ow-khoa-list" class="dr-ow-list"><div class="dr-ow-empty">Đang tải danh sách khoa...</div></div> </div> <div class="dr-ow-card"> <h4 class="dr-ow-title">Phòng thuộc khoa đã chọn</h4> <div id="dr-ow-room-list" class="dr-ow-list"><div class="dr-ow-empty">Chưa chọn khoa.</div></div> </div> </div> `; const khoaListEl = container.querySelector('#dr-ow-khoa-list'); const roomListEl = container.querySelector('#dr-ow-room-list'); const ls = window.localStorage; const SELECTED_KHOA_KEY = 'bsnt_khoa_dashboard'; const ROOMS_CACHE_KEY = (k) => `dr_ow_rooms_${k}`; // Helper: render rooms function renderRooms(rooms) { if (!rooms || rooms.length === 0) { roomListEl.innerHTML = `<div class="dr-ow-empty">Không có phòng.</div>`; return; } roomListEl.innerHTML = ''; rooms.forEach(r => { const div = document.createElement('div'); div.className = 'dr-ow-item'; div.textContent = r.name || r.code || 'Phòng'; // keep attributes for later use div.dataset.id = r.id || ''; div.dataset.code = r.code || ''; div.dataset.name = r.name || ''; div.dataset.khoA_ID = r.khoA_ID || ''; div.dataset.tanG_ID = r.tanG_ID || ''; roomListEl.appendChild(div); }); } // Debounced save to API for default khoa const debouncedSave = debounce(async (khoaId) => { try { if (!doctorName) return; let obj = checklistObj || await SettingsService.loadSettingsPhieu(doctorName); if (!obj) { const created = await SettingsService.createSettingsPhieu(doctorName); if (created && created.isValid) obj = await SettingsService.loadSettingsPhieu(doctorName); } if (!obj) return; const current = SettingsService.parseSettingsState(obj) || {}; const next = { ...current, openWorld: { ...(current.openWorld || {}), defaultKhoa: String(khoaId || '') } }; await SettingsService.updateSettingsState(obj, next); } catch (e) { console.warn('Save default khoa failed', e); } }, 600); // Render khoa list and wire selection async function renderKhoaList() { try { const khoa = await fetchKhoaPhong(); let selected = (ls && ls.getItem(SELECTED_KHOA_KEY)) || ''; khoaListEl.innerHTML = ''; khoa.forEach(k => { const div = document.createElement('div'); const kId = String(k.id); const isSelected = !!selected && selected === kId; div.className = 'dr-ow-item' + (isSelected ? ' active' : ''); const name = (k && k.name) || 'Khoa'; div.innerHTML = `<span>${name}</span>` + (isSelected ? `<span class="dr-ow-badge">Đã chọn</span>` : ''); div.addEventListener('click', async () => { // update selection locally Array.from(khoaListEl.querySelectorAll('.dr-ow-item')).forEach(el => el.classList.remove('active')); div.classList.add('active'); // set badge Array.from(khoaListEl.querySelectorAll('.dr-ow-badge')).forEach(b => b.remove()); div.insertAdjacentHTML('beforeend', `<span class="dr-ow-badge">Đã chọn</span>`); // persist to localStorage for dashboard compatibility try { ls && ls.setItem(SELECTED_KHOA_KEY, kId); } catch(_) {} // fetch rooms for selected khoa const rooms = await fetchRoomsByKhoa(kId); renderRooms(rooms); try { ls && ls.setItem(ROOMS_CACHE_KEY(kId), JSON.stringify(rooms || [])); } catch(_) {} // save via API for per-doctor settings debouncedSave(kId); }); khoaListEl.appendChild(div); }); // Auto-load rooms for current selection if (selected) { try { const cached = ls && ls.getItem(ROOMS_CACHE_KEY(selected)); if (cached) { try { renderRooms(JSON.parse(cached)); } catch { /* ignore */ } } else { const rooms = await fetchRoomsByKhoa(selected); renderRooms(rooms); try { ls && ls.setItem(ROOMS_CACHE_KEY(selected), JSON.stringify(rooms || [])); } catch(_) {} } } catch(_) {} } } catch (e) { khoaListEl.innerHTML = `<div class="dr-ow-empty">Lỗi tải danh sách khoa.</div>`; console.warn('LoadKhoaPhong failed', e); } } renderKhoaList(); } module.exports = { mountOpenWorldTab }; },{"../services/apiService":26,"../services/settingsService":31}],24:[function(require,module,exports){ // settings.js - Render a settings page similar to dashboard, triggered by ?caidat const SettingsService = require('../services/settingsService'); let mountOpenWorldTab; try { ({ mountOpenWorldTab } = require('./page.settings-open-world')); } catch (e) { try { ({ mountOpenWorldTab } = require('../settings-open-world')); } catch (e2) { console.warn('Open World settings module not found', e2); } } async function showSettingsIfNeeded() { // Support selecting tab via ?caidat or ?tab param, e.g., ?caidat=account or ?caidat, ?tab=discharge const u = new URL(window.location.href); const caidatParam = u.searchParams.get('caidat'); const tabParam = u.searchParams.get('tab'); const targetTab = (caidatParam && caidatParam !== 'true') ? caidatParam : (tabParam || 'discharge'); if (!(/[?&](caidat)($|=|&)/.test(window.location.search))) return; // Reset page and mount a two-column layout with tabs document.body.innerHTML = ''; const styles = document.createElement('style'); styles.textContent = ` .dr-st-wrap{display:flex; min-height:100vh; color:#111827; background:#fff; font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif} .dr-st-left{width:260px; border-right:1px solid #e5e7eb; background:#fafafa} .dr-st-left h2{margin:16px; font-size:18px} .dr-st-menu{display:flex; flex-direction:column; gap:8px; padding:0 12px 16px} .dr-st-menu button{appearance:none; border:1px solid #e5e7eb; background:#fff; padding:10px 12px; border-radius:10px; text-align:left; cursor:pointer} .dr-st-menu button.active{border-color:#2563eb; box-shadow:0 0 0 2px rgba(37,99,235,.15) inset} .dr-st-right{flex:1; min-width:0;} .dr-st-head{display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid #e5e7eb} .dr-st-title{margin:0; font-size:18px} .dr-st-content{padding:16px 20px} .dr-st-row{display:flex; gap:8px; align-items:center; margin-bottom:8px} .dr-st-input{flex:1; padding:8px 10px; border:1px solid #e5e7eb; border-radius:8px} .dr-st-btn{appearance:none; border:1px solid #e5e7eb; background:#fff; padding:8px 12px; border-radius:8px; cursor:pointer} .dr-st-btn.primary{border-color:#2563eb; background:#2563eb; color:#fff} .dr-st-list{display:flex; flex-direction:column; gap:8px; margin:12px 0} .dr-st-tab{display:none} .dr-st-tab.active{display:block} .dr-st-footer{padding:12px 20px; color:#6b7280; border-top:1px solid #e5e7eb} `; document.head.appendChild(styles); const wrap = document.createElement('div'); wrap.className = 'dr-st-wrap'; // Left menu const left = document.createElement('aside'); left.className = 'dr-st-left'; left.innerHTML = ` <h2>Cài đặt</h2> <div class="dr-st-menu"> <button data-tab="discharge" class="${targetTab==='discharge'?'active':''}">Lời dặn dò ra viện</button> <button data-tab="account" class="${targetTab==='account'?'active':''}">Account</button> <button data-tab="openworld" class="${targetTab==='openworld'?'active':''}">Thông tin khoa/phòng</button> <button data-tab="otm-surgeons" class="${targetTab==='otm-surgeons'?'active':''}">Quản lý phẫu thuật</button> </div> <div class="dr-st-footer" id="dr-st-doctor"></div> `; // Right content with header and tabs const right = document.createElement('section'); right.className = 'dr-st-right'; right.innerHTML = ` <div class="dr-st-head"> <h3 class="dr-st-title">${targetTab==='account'?'Account':(targetTab==='openworld'?'Thông tin khoa/phòng':(targetTab==='otm-surgeons'?'Quản lý phẫu thuật':'Lời dặn dò ra viện'))}</h3> <div> <button class="dr-st-btn" id="reload-tab">Tải lại</button> <button class="dr-st-btn primary" id="save-tab">Lưu</button> </div> </div> <div class="dr-st-content"> <div id="tab-discharge" class="dr-st-tab ${targetTab==='discharge'?'active':''}"> <p style="margin:0 0 8px; color:#6b7280">Danh sách các lời dặn dò ra viện. Bạn có thể thêm/xóa và chỉnh sửa.</p> <div id="discharge-list" class="dr-st-list"></div> <button id="add-discharge" class="dr-st-btn">+ Thêm mục</button> </div> <div id="tab-account" class="dr-st-tab ${targetTab==='account'?'active':''}"> <div style="margin-bottom:12px; padding:10px; border:1px solid #fde68a; background:#fffbeb; border-radius:8px; color:#92400e"> <b>Lưu ý bảo mật:</b> Thông tin dưới đây chỉ lưu trên thiết bị (LocalStorage của trình duyệt), không gửi lên máy chủ. Hãy sử dụng trên máy tính cá nhân tin cậy. Nếu dùng máy công cộng, KHÔNG nhập mật khẩu ở đây. </div> <div id="dr-acc-toggle-wrap" style="margin:8px 0 16px;"></div> <div style="margin-top:8px; color:#6b7280; font-size:13px; line-height:1.5;"> Khi bật "tự động login", lúc vào trang <code>/Home/Login</code> tiện ích sẽ tự điền Tên đăng nhập và Mật khẩu rồi nhấn Đăng nhập, sau đó chờ 1.5 giây và mở <code>/?nln</code>. Tắt tùy chọn này nếu bạn không muốn tự động đăng nhập. </div> <div id="dr-acc-grid" style="display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap:12px; margin-top:12px;"></div> </div> <div id="tab-openworld" class="dr-st-tab ${targetTab==='openworld'?'active':''}"> <div id="dr-openworld-container"></div> </div> <div id="tab-otm-surgeons" class="dr-st-tab ${targetTab==='otm-surgeons'?'active':''}"> <div id="dr-otm-surgeons-container"></div> </div> </div> `; wrap.appendChild(left); wrap.appendChild(right); document.body.appendChild(wrap); // Load settings state let { doctorName, checklistObj, settings } = await SettingsService.getOrCreateSettings(); const titleEl = right.querySelector('.dr-st-title'); const listEl = right.querySelector('#discharge-list'); const doctorEl = left.querySelector('#dr-st-doctor'); if (doctorEl) doctorEl.textContent = doctorName ? `Bác sĩ: ${doctorName}` : 'Bác sĩ: (không xác định)'; // Account tab: multi-account manager (localStorage only) const ls = window.localStorage; const ACC_KEY = 'dr_accounts_json'; const DEF_KEY = 'dr_acc_default'; const AUTO_KEY = 'dr_acc_autologin'; function readAccounts() { try { return JSON.parse(ls.getItem(ACC_KEY) || '[]'); } catch(_) { return []; } } function writeAccounts(arr) { ls.setItem(ACC_KEY, JSON.stringify(arr || [])); } function readDefault() { return ls.getItem(DEF_KEY) || ''; } function writeDefault(u) { ls.setItem(DEF_KEY, u || ''); } const grid = right.querySelector('#dr-acc-grid'); function renderGrid() { if (!grid) return; grid.innerHTML = ''; const accounts = readAccounts(); let def = readDefault(); // If only one account, auto set as default if (accounts.length === 1) { const only = accounts[0]; if (only && only.username && def !== only.username) { writeDefault(only.username); def = only.username; } } accounts.forEach((acc, idx) => { const box = document.createElement('div'); box.style.cssText = 'border:1px solid #e5e7eb; border-radius:10px; padding:10px; position:relative; background:#fff;'; const radioId = `dr-acc-default-${idx}`; box.innerHTML = ` <button class="dr-acc-remove" title="Xóa" style="position:absolute; right:8px; top:8px; background:#dc2626; color:#fff; border:none; border-radius:6px; padding:2px 6px; cursor:pointer;">X</button> <div class="dr-st-row" style="margin-top:8px;"> <label style="width:100px">Tiêu đề</label> <input class="dr-st-input dr-acc-title" type="text" value="${(acc.title||'').replace(/"/g,'"')}" placeholder="VD: BS. ABC" /> </div> <div class="dr-st-row"> <label style="width:100px">Tên đăng nhập</label> <input class="dr-st-input dr-acc-username" type="text" value="${(acc.username||'').replace(/"/g,'"')}" placeholder="Tên đăng nhập" /> </div> <div class="dr-st-row"> <label style="width:100px">Mật khẩu</label> <input class="dr-st-input dr-acc-password" type="password" value="${(acc.password||'').replace(/"/g,'"')}" placeholder="Mật khẩu" /> </div> <div class="dr-st-row"> <input id="${radioId}" type="radio" name="dr-acc-default" class="dr-acc-default" ${def && def===acc.username ? 'checked' : ''} /> <label for="${radioId}" style="margin-left:6px; cursor:pointer;">Tài khoản mặc định</label> </div> `; box.querySelector('.dr-acc-remove').addEventListener('click', () => { if (confirm('Xóa tài khoản này?')) { const arr = readAccounts(); arr.splice(idx,1); writeAccounts(arr); if (def === acc.username) writeDefault(''); if (arr.length === 1) { const u = arr[0] && arr[0].username || ''; if (u) writeDefault(u); } renderGrid(); } }); box.querySelector('.dr-acc-title').addEventListener('input', (e) => { const arr = readAccounts(); if (arr[idx]) { arr[idx].title = e.target.value; writeAccounts(arr); } }); box.querySelector('.dr-acc-username').addEventListener('input', (e) => { const arr = readAccounts(); if (arr[idx]) { const oldU = arr[idx].username || ''; arr[idx].username = e.target.value; writeAccounts(arr); const curDef = readDefault(); if (curDef === oldU) writeDefault(e.target.value || ''); } }); box.querySelector('.dr-acc-password').addEventListener('input', (e) => { const arr = readAccounts(); if (arr[idx]) { arr[idx].password = e.target.value; writeAccounts(arr); } }); box.querySelector('.dr-acc-default').addEventListener('change', (e) => { if (e.target.checked) writeDefault(acc.username || ''); }); // Ensure label click also sets default (redundant with for=, but safe) const lbl = box.querySelector(`label[for="${radioId}"]`); if (lbl) { lbl.addEventListener('click', () => { const inp = box.querySelector(`#${radioId}`); if (inp) { inp.checked = true; writeDefault(acc.username || ''); } }); } grid.appendChild(box); }); // Add box const addBox = document.createElement('div'); addBox.style.cssText = 'border:1px dashed #cbd5e1; border-radius:10px; padding:10px; display:flex; align-items:center; justify-content:center; cursor:pointer; color:#6b7280; background:#fafafa;'; addBox.innerHTML = '<div style="font-size:28px; line-height:1;">+</div>'; addBox.title = 'Thêm tài khoản'; addBox.addEventListener('click', () => { const arr = readAccounts(); arr.push({ title:'', username:'', password:'' }); writeAccounts(arr); renderGrid(); }); grid.appendChild(addBox); } renderGrid(); // Top-level auto-login toggle (shared component) try { const { createAutoLoginToggle, applyToggleStyles } = require('../components/autoLoginToggle'); const wrap = right.querySelector('#dr-acc-toggle-wrap'); if (wrap) { const enabled = ls.getItem(AUTO_KEY) === '1'; const toggle = createAutoLoginToggle({ enabled, onToggle: () => { const cur = ls.getItem(AUTO_KEY) === '1'; ls.setItem(AUTO_KEY, cur ? '0' : '1'); applyToggleStyles(toggle, !cur); }, onDblClick: () => {}, title: 'Bật/tắt tự động login' }); wrap.appendChild(toggle); } } catch(_) {} const renderDischarge = (items) => { listEl.innerHTML = ''; (items || []).forEach(text => { const row = document.createElement('div'); row.className = 'dr-st-row'; row.innerHTML = ` <input class="dr-st-input" type="text" value="${(text || '').replace(/"/g,'"')}" placeholder="Nhập lời dặn dò..." /> <button class="dr-st-btn remove-row" title="Xóa">Xóa</button> `; listEl.appendChild(row); }); }; renderDischarge(settings && settings.danDoRaVien ? settings.danDoRaVien : SettingsService.getDefaultSettings().danDoRaVien); // Left menu switching (future tabs-ready) left.addEventListener('click', (e) => { const btn = e.target.closest('button[data-tab]'); if (!btn) return; left.querySelectorAll('button').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const tab = btn.dataset.tab; titleEl.textContent = tab === 'discharge' ? 'Lời dặn dò ra viện' : (tab === 'account' ? 'Account' : (tab === 'openworld' ? 'Thông tin khoa/phòng' : (tab === 'otm-surgeons' ? 'Quản lý phẫu thuật' : btn.textContent.trim()))); right.querySelectorAll('.dr-st-tab').forEach(t => t.classList.remove('active')); const target = right.querySelector(`#tab-${tab}`); if (target) target.classList.add('active'); // Update URL (no reload) to reflect current tab for deep linking try { const url = new URL(window.location.href); url.searchParams.set('caidat', tab); window.history.replaceState({}, '', url); } catch(_) {} // Mount Open World content when its tab is shown if (tab === 'openworld') { const mountEl = right.querySelector('#dr-openworld-container'); if (mountEl && !mountEl.dataset.mounted) { mountEl.dataset.mounted = '1'; mountOpenWorldTab({ container: mountEl, doctorName, checklistObj, settings }); } } else if (tab === 'otm-surgeons') { try { const { mountOTMSurgeonsTab } = require('../pages/page.settings.otm.quanlyphauthuat'); const mountEl = right.querySelector('#dr-otm-surgeons-container'); if (mountEl && !mountEl.dataset.mounted) { mountEl.dataset.mounted = '1'; mountOTMSurgeonsTab({ container: mountEl }); } } catch (e) { console.warn('OTM Surgeons tab mount failed', e); } } }); // Right actions right.addEventListener('click', async (e) => { if (e.target.id === 'add-discharge') { const row = document.createElement('div'); row.className = 'dr-st-row'; row.innerHTML = ` <input class="dr-st-input" type="text" placeholder="Nhập lời dặn dò..." /> <button class="dr-st-btn remove-row" title="Xóa">Xóa</button> `; listEl.appendChild(row); return; } if (e.target.classList && e.target.classList.contains('remove-row')) { e.target.closest('.dr-st-row')?.remove(); return; } if (e.target.id === 'reload-tab') { const data = await SettingsService.getOrCreateSettings(); doctorName = data.doctorName; checklistObj = data.checklistObj; settings = data.settings || SettingsService.getDefaultSettings(); renderDischarge(settings.danDoRaVien || []); if (doctorEl) doctorEl.textContent = doctorName ? `Bác sĩ: ${doctorName}` : 'Bác sĩ: (không xác định)'; return; } if (e.target.id === 'save-tab') { const values = Array.from(listEl.querySelectorAll('input')).map(i => i.value.trim()).filter(Boolean); const next = { ...(settings || {}), danDoRaVien: values }; // Ensure checklist exists if (!checklistObj && doctorName) { const created = await SettingsService.createSettingsPhieu(doctorName); if (created && created.isValid) { checklistObj = await SettingsService.loadSettingsPhieu(doctorName); } } if (!checklistObj) { alert('Không thể lưu: chưa có phiếu cài đặt.'); return; } const ok = await SettingsService.updateSettingsState(checklistObj, next); if (ok) { settings = next; alert('Đã lưu cài đặt'); } else { alert('Lưu thất bại'); } } }); // Account tab no longer uses single username/password fields; managed via grid. // Mount Open World if deep-linked initially try { if (targetTab === 'openworld') { const mountEl = right.querySelector('#dr-openworld-container'); if (mountEl) { mountEl.dataset.mounted = '1'; mountOpenWorldTab({ container: mountEl, doctorName, checklistObj, settings }); } } else if (targetTab === 'otm-surgeons') { try { const { mountOTMSurgeonsTab } = require('../pages/page.settings.otm.quanlyphauthuat'); const mountEl = right.querySelector('#dr-otm-surgeons-container'); if (mountEl) { mountEl.dataset.mounted = '1'; mountOTMSurgeonsTab({ container: mountEl }); } } catch (e) { console.warn('Init OTM Surgeons tab failed', e); } } } catch(_) {} } module.exports = { showSettingsIfNeeded }; },{"../components/autoLoginToggle":5,"../pages/page.settings.otm.quanlyphauthuat":25,"../services/settingsService":31,"../settings-open-world":33,"./page.settings-open-world":23}],25:[function(require,module,exports){ // page.settings.otm.quanlyphauthuat.js - Manage surgeons list per Khoa using OTM users const ApiService = require('../services/apiService'); const SurgeonSettingsService = require('../services/surgeonSettingsService'); const { getSelectedKhoa } = require('../utils/khoaUtils'); function stylesOnce() { if (document.getElementById('dr-otm-surgeon-styles')) return; const st = document.createElement('style'); st.id = 'dr-otm-surgeon-styles'; st.textContent = ` .dr-os-wrap { display:flex; flex-direction:column; gap:12px; } .dr-os-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } .dr-os-select, .dr-os-search { padding:8px 10px; border:1px solid #e5e7eb; border-radius:8px; } .dr-os-columns { display:grid; grid-template-columns: 320px 1fr; gap:12px; align-items:start; } .dr-os-selected { border:1px solid #e5e7eb; border-radius:10px; padding:10px; background:#fff; max-height:55vh; overflow:auto; } .dr-os-selected h4 { margin:0 0 8px; font-size:14px; color:#334155; } .dr-os-chip { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; background:#f1f5f9; border:1px solid #e5e7eb; border-radius:999px; margin:4px; font-size:13px; } .dr-os-chip button { appearance:none; border:none; background:transparent; cursor:pointer; color:#64748b; } .dr-os-list { border:1px solid #e5e7eb; border-radius:10px; padding:10px; max-height:55vh; overflow:auto; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .dr-os-item { display:flex; align-items:center; gap:8px; padding:8px; border:1px solid #e5e7eb; border-radius:8px; background:#fff; } .dr-os-actions { display:flex; gap:8px; } .dr-os-btn { appearance:none; border:1px solid #e5e7eb; background:#fff; padding:8px 12px; border-radius:8px; cursor:pointer } .dr-os-btn.primary { border-color:#2563eb; background:#2563eb; color:#fff } @media (max-width: 1100px) { .dr-os-columns { grid-template-columns: 1fr; } } @media (max-width: 900px) { .dr-os-list { grid-template-columns: 1fr; } } `; document.head.appendChild(st); } // In-memory cache for OTM users (heavy list) const _otmUsersCache = { list: null, at: 0 }; function getBearerToken() { try { return localStorage.getItem('otm_bearer_token') || ''; } catch { return ''; } } async function ensureOTMUsers() { if (Array.isArray(_otmUsersCache.list) && _otmUsersCache.list.length > 0) return _otmUsersCache.list; const token = getBearerToken(); if (!token) throw new Error('NO_TOKEN'); // Use the same query and cache-busting approach as the content script to avoid 304/empty payloads const url = `https://otm.tahospital.vn/api/user?ishsoft=null&page=1&limit=10000&_=${Date.now()}`; const res = await fetch(url, { headers: { 'accept': 'application/json, text/plain, */*', 'authorization': `Bearer ${token}`, 'logintype': '2', 'siteid': '1' }, method: 'GET', mode: 'cors', cache: 'no-store', credentials: 'include' }); if (!res.ok) { if (res.status === 401 || res.status === 403) throw new Error('TOKEN_EXPIRED'); throw new Error('HTTP_' + res.status); } const json = await res.json(); // Robustly pick the array from possible containers function pickArrayPayload(obj) { if (Array.isArray(obj)) return obj; if (!obj || typeof obj !== 'object') return []; const candidates = ['data', 'items', 'result', 'rows', 'content', 'users', 'records', 'list']; for (const k of candidates) { const v = obj[k]; if (Array.isArray(v)) return v; if (v && typeof v === 'object') { for (const kk of candidates) { const v2 = v[kk]; if (Array.isArray(v2)) return v2; } } } return []; } const arr = pickArrayPayload(json); _otmUsersCache.list = arr; _otmUsersCache.at = Date.now(); return arr; } function subscribeOTMMessages(onUsers, onProgress, onError) { // GM listeners (preferred) try { if (typeof GM !== 'undefined' && GM.addValueChangeListener) { const unsub = []; unsub.push(GM.addValueChangeListener('otm_success', (n, o, v) => { try { const parsed = typeof v === 'string' ? JSON.parse(v) : v; const data = parsed && parsed.data; if (data && Array.isArray(data.otmUsers)) { onUsers(data.otmUsers); } } catch(_) {} })); unsub.push(GM.addValueChangeListener('otm_progress', (n, o, v) => { try { const parsed = typeof v === 'string' ? JSON.parse(v) : v; onProgress && onProgress(parsed && parsed.data); } catch(_) {} })); unsub.push(GM.addValueChangeListener('otm_error', (n, o, v) => { try { const parsed = typeof v === 'string' ? JSON.parse(v) : v; onError && onError(parsed && parsed.data); } catch(_) {} })); return () => { try { unsub.forEach(x => typeof x === 'function' && x()); } catch(_) {} }; } } catch(_) {} // Fallback: poll localStorage keys once per second for a short time const tid = setInterval(() => { try { const successData = localStorage.getItem('otm_success'); if (successData) { localStorage.removeItem('otm_success'); const parsed = JSON.parse(successData); const data = parsed && parsed.data; if (data && Array.isArray(data.otmUsers)) onUsers(data.otmUsers); } const progressData = localStorage.getItem('otm_progress'); if (progressData) { localStorage.removeItem('otm_progress'); const parsed = JSON.parse(progressData); onProgress && onProgress(parsed && parsed.data); } const errorData = localStorage.getItem('otm_error'); if (errorData) { localStorage.removeItem('otm_error'); const parsed = JSON.parse(errorData); onError && onError(parsed && parsed.data); } } catch(_) {} }, 1000); return () => clearInterval(tid); } function openOTMUsersTab() { const url = 'https://otm.tahospital.vn/?otm-fetch-users=1'; if (typeof GM !== 'undefined' && GM.openInTab) { try { GM.openInTab(url, { active: false, insert: true }); return; } catch(_) {} } window.open(url, '_blank'); } function filterSurgeonUsers(users) { if (!Array.isArray(users)) return []; // Accepted doctor title prefixes const prefixes = [ 'BS.', 'Bác sĩ', 'BSCKI', 'BS.CKI', 'BS.CKII', 'ThS.BS', 'ThS.BS.CKI', 'ThS.BS.CKII', 'PGS.TS.BS', 'PGS.TS.BS.CKI', 'PGS.TS.BS.CKII', 'TS.BS' ].map(p => p.toLowerCase()); const norm = s => (s || '').toString().trim(); function looksLikeDoctorName(name) { const n = norm(name); if (!n) return false; const ln = n.toLowerCase(); return prefixes.some(p => ln.startsWith(p)); } // Return mapped consistent shape: { id, fullname, khoaId? } return users .filter(u => looksLikeDoctorName(u?.fullname || u?.name || '')) .map(u => ({ id: u.id ?? u.taid ?? u.userid ?? null, fullname: u.fullname || u.name || '', raw: u })); } function uniqueByFullname(arr) { const seen = new Set(); const out = []; for (const x of arr || []) { const n = (x.fullname || '').trim(); if (!n || seen.has(n)) continue; seen.add(n); out.push({ fullname: n }); } return out; } async function render(container, state) { stylesOnce(); container.innerHTML = ` <div class="dr-os-wrap"> <div style="color:#6b7280">Chọn khoa và tick các bác sĩ phẫu thuật cần dùng. Danh sách lấy từ OTM, chỉ tải một lần mỗi phiên.</div> <div class="dr-os-row"> <label>Khoa:</label> <select id="dr-os-khoa" class="dr-os-select"></select> <input id="dr-os-search" class="dr-os-search" placeholder="Tìm theo tên..." /> <div class="dr-os-actions"> <button id="dr-os-reload-users" class="dr-os-btn">Tải lại DS từ OTM</button> </div> </div> <div id="dr-os-info" style="color:#6b7280"></div> <div class="dr-os-columns"> <div class="dr-os-selected"> <h4>Đã chọn (<span id="dr-os-selected-count">0</span>)</h4> <div id="dr-os-selected-chips"></div> </div> <div id="dr-os-list" class="dr-os-list"></div> </div> </div> `; const khoaSel = container.querySelector('#dr-os-khoa'); const searchInput = container.querySelector('#dr-os-search'); const listEl = container.querySelector('#dr-os-list'); const chipsWrap = container.querySelector('#dr-os-selected-chips'); const selCountEl = container.querySelector('#dr-os-selected-count'); const infoEl = container.querySelector('#dr-os-info'); // Load khoa list via ApiService let khoaList = []; try { khoaList = await ApiService.fetchKhoaPhong(); } catch { khoaList = []; } khoaSel.innerHTML = ''; for (const k of khoaList) { const opt = document.createElement('option'); opt.value = String(k.id); opt.textContent = k.name || `Khoa ${k.id}`; khoaSel.appendChild(opt); } const selectedKhoa = getSelectedKhoa(khoaList[0] ? String(khoaList[0].id) : '551'); if (khoaSel.querySelector(`option[value="${selectedKhoa}"]`)) khoaSel.value = selectedKhoa; let otmUsers = []; let surgeonUsers = []; let selected = new Set(); async function loadUsers() { infoEl.textContent = 'Đang tải danh sách người dùng OTM...'; try { otmUsers = await ensureOTMUsers(); surgeonUsers = filterSurgeonUsers(otmUsers); infoEl.textContent = `Lọc được ${surgeonUsers.length} bác sĩ từ OTM.`; } catch (e) { if (e && (e.message === 'NO_TOKEN' || e.message === 'TOKEN_EXPIRED')) { infoEl.textContent = 'Chưa có/ hết hạn token OTM. Đang mở tab OTM để lấy dữ liệu...'; const unsubscribe = subscribeOTMMessages((users) => { _otmUsersCache.list = users; _otmUsersCache.at = Date.now(); otmUsers = users; surgeonUsers = filterSurgeonUsers(otmUsers); infoEl.textContent = `Đã tải ${users.length} người dùng từ OTM. Lọc được ${surgeonUsers.length} bác sĩ.`; renderList(); try { unsubscribe && unsubscribe(); } catch(_) {} }, (prog) => { // optional progress updates }, (err) => { infoEl.textContent = (err && err.message) ? err.message : 'Lỗi khi lấy dữ liệu OTM'; }); openOTMUsersTab(); } else { infoEl.textContent = e && e.message ? e.message : 'Không thể tải danh sách OTM'; surgeonUsers = []; } } } function renderSelectedChips() { const arr = Array.from(selected.values()).sort((a,b)=>a.localeCompare(b)); chipsWrap.innerHTML = ''; for (const name of arr) { const chip = document.createElement('span'); chip.className = 'dr-os-chip'; chip.innerHTML = `<span>${name}</span><button title="Bỏ chọn">✕</button>`; const btn = chip.querySelector('button'); btn.addEventListener('click', async () => { selected.delete(name); renderSelectedChips(); renderList(); // Auto-save on removal const khoaId = khoaSel.value; const names = Array.from(selected.values()); try { await SurgeonSettingsService.saveSurgeonList(khoaId, names); } catch(_) {} }); chipsWrap.appendChild(chip); } selCountEl.textContent = String(arr.length); } function renderList() { const q = (searchInput.value || '').trim().toLowerCase(); const items = surgeonUsers.filter(u => !q || (u.fullname || '').toLowerCase().includes(q)); listEl.innerHTML = ''; for (const u of items) { const id = `dr-os-${btoa(unescape(encodeURIComponent(u.fullname))).replace(/=/g,'')}`; const div = document.createElement('label'); div.className = 'dr-os-item'; div.innerHTML = ` <input type="checkbox" data-name="${u.fullname.replace(/"/g,'"')}" ${selected.has(u.fullname) ? 'checked' : ''} /> <span>${u.fullname}</span> `; const cb = div.querySelector('input[type="checkbox"]'); cb.addEventListener('change', async () => { if (cb.checked) selected.add(u.fullname); else selected.delete(u.fullname); renderSelectedChips(); // Auto-save on change const khoaId = khoaSel.value; const names = Array.from(selected.values()); try { await SurgeonSettingsService.saveSurgeonList(khoaId, names); } catch(_) {} }); listEl.appendChild(div); } if (items.length === 0) listEl.innerHTML = '<div class="dr-os-item" style="opacity:.7">Không có kết quả.</div>'; } async function loadSelectionForCurrentKhoa() { const khoaId = khoaSel.value; infoEl.textContent = 'Đang tải danh sách đã lưu...'; try { const { list } = await SurgeonSettingsService.loadSurgeonList(khoaId); selected = new Set((list || []).map(x => (typeof x === 'string' ? x : x.fullname)).filter(Boolean)); infoEl.textContent = `Đã tải danh sách lưu (${selected.size}).`; renderList(); renderSelectedChips(); } catch (e) { infoEl.textContent = 'Không thể tải danh sách đã lưu'; } } await loadUsers(); await loadSelectionForCurrentKhoa(); renderList(); searchInput.addEventListener('input', () => renderList()); khoaSel.addEventListener('change', async () => { await loadSelectionForCurrentKhoa(); }); container.querySelector('#dr-os-reload-users').addEventListener('click', async () => { // Always fetch via OTM tab to mirror otm-entry flow infoEl.textContent = 'Đang mở tab OTM để tải lại danh sách...'; const unsubscribe = subscribeOTMMessages((users) => { _otmUsersCache.list = users; _otmUsersCache.at = Date.now(); otmUsers = users; surgeonUsers = filterSurgeonUsers(otmUsers); infoEl.textContent = `Đã tải ${users.length} người dùng từ OTM. Lọc được ${surgeonUsers.length} bác sĩ.`; renderList(); renderSelectedChips(); try { unsubscribe && unsubscribe(); } catch(_) {} }, null, (err) => { infoEl.textContent = (err && err.message) ? err.message : 'Lỗi khi lấy dữ liệu OTM'; }); openOTMUsersTab(); }); container.querySelector('#dr-os-save').addEventListener('click', async () => { const khoaId = khoaSel.value; const names = Array.from(selected.values()); // Removed explicit Save button; selection changes are auto-saved. }); } async function mountOTMSurgeonsTab({ container }) { if (!container) return; await render(container, {}); } module.exports = { mountOTMSurgeonsTab }; },{"../services/apiService":26,"../services/surgeonSettingsService":32,"../utils/khoaUtils":39}],26:[function(require,module,exports){ // apiService.js - Centralized API service const { getSelectedKhoa } = require('../utils/khoaUtils'); const ApiService = { /** * Load list of khoa/phòng (departments) */ async fetchKhoaPhong() { const body = new URLSearchParams(); body.set('loaibn', ''); body.set('makp', ''); const res = await fetch('/ToDieuTri/LoadKhoaPhong', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', 'Accept': '*/*' }, body }); const json = await res.json(); return (json && json.data) || []; }, /** * Load rooms by khoa id */ async fetchRoomsByKhoa(khoaId) { const body = new URLSearchParams(); body.set('code', String(khoaId || '')); const res = await fetch('/ToDieuTri/LoadRoom', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', 'Accept': '*/*' }, body }); const json = await res.json(); return (json && json.data) || []; }, /** * Fetch patient data from ToDieuTri endpoint */ async fetchToDieuTriData() { try { const formData = new FormData(); const khoa = getSelectedKhoa('551'); formData.append('khoa', khoa); formData.append('tk', '0'); formData.append('cbAll', '1'); const response = await fetch('/ToDieuTri/Search', { method: 'POST', body: formData, credentials: 'include', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': '*/*' }, }); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } let data; const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { data = await response.json(); } else { data = await response.text(); } return data; } catch (error) { console.error('Lỗi khi lấy dữ liệu ToDieuTri:', error); throw error; } }, /** * Update checklist data */ async updateChecklistData(oldData, checklistState, { signal } = {}) { try { const formData = new FormData(); // Add all old data fields for (const key in oldData) { if (Object.prototype.hasOwnProperty.call(oldData, key)) { formData.append(key.toLowerCase(), oldData[key] == null ? '' : oldData[key]); } } // Update checklist state formData.set('chuky', JSON.stringify(checklistState)); const response = await fetch('/ERM_PHIEUCCTHONGTINVACAMKETNHAPVIEN/EditAjax', { method: 'POST', credentials: 'include', body: formData, signal }); return response.json(); } catch (error) { console.error('Lỗi cập nhật checklist phiếu:', error); throw error; } }, /** * Create new checklist for patient */ async createChecklistForPatient(patient) { try { const formData = new FormData(); formData.append('status', '1'); formData.append('thebaohiemyte', patient.thebaohiemyte || 'Không'); formData.append('chuky', '{}'); formData.append('khac', '--*--'); formData.append('khu', patient.khu || '1'); formData.append('mabn', patient.mabn + 9898); formData.append('bieumauid', '027'); // prefer patient's makp; fallback to selected khoa const makp = (patient.makp || getSelectedKhoa('551')); formData.append('makp', makp); formData.append('__model', 'TAH.Entity.Model.PHIEUCCTHONGTINVACAMKETNHAPVIEN.ERM_PHIEUCCTHONGTINVACAMKETNHAPVIEN'); formData.append('actiontype', ''); formData.append('hoten', (patient.hoten || '') + "%"); formData.append('ngaysinh', '10/10/1999'); formData.append('gioitinh', patient.phai === 1 ? 'Nữ' : 'Nam'); formData.append('diachi', patient.diachi || ''); formData.append('sdt', patient.sdt || ''); const response = await fetch('/ERM_PHIEUCCTHONGTINVACAMKETNHAPVIEN/CreateAjax', { method: 'POST', credentials: 'include', body: formData }); return response.json(); } catch (error) { console.error('Lỗi tạo mới checklist phiếu:', error); throw error; } } }; module.exports = ApiService; },{"../utils/khoaUtils":39}],27:[function(require,module,exports){ // checklistService.js - Centralized checklist management const DateUtils = require('../utils/dateUtils'); const ApiService = require('./apiService'); const SaveQueue = require('./saveQueue'); // In-memory cache to dedupe checklist fetches per patient and date range const _checklistCache = new Map(); function _makeCacheKey(mabn, tungay, denngay) { return `${String(mabn)}|${String(tungay)}|${String(denngay)}`; } const ChecklistService = { // Expose small helpers for cache invalidation (internal use) _invalidateCacheForMabn(mabn) { try { const prefix = `${String(mabn)}|`; for (const key of _checklistCache.keys()) { if (key.startsWith(prefix)) _checklistCache.delete(key); } } catch (_) {} }, async drainSaveQueue() { return await SaveQueue.drain(async ({ checklistObj, checklistState }) => { try { const res = await ApiService.updateChecklistData(checklistObj, checklistState); const ok = res && (res.Status == 1 || res.isValid); if (ok) { try { const mabn = checklistObj && (checklistObj.mabn || checklistObj.MABN || checklistObj.MaBN); let ngayvv = (checklistObj && (checklistObj.tungay || checklistObj.ngayvv || checklistObj.NgayVV)) || null; if (mabn) { if (ngayvv) { const { tungay, denngay } = DateUtils.getChecklistDateRange(ngayvv); const key = _makeCacheKey(mabn, tungay, denngay); _checklistCache.delete(key); } this._invalidateCacheForMabn(mabn); } } catch (_) {} } return ok; } catch (_) { return false; } }); }, /** * Load checklist data for a patient */ async loadChecklistData(patient, options = {}) { const originalMabn = patient.mabn; const mabnWith9898 = patient.mabn + 9898; const { tungay, denngay } = DateUtils.getChecklistDateRange(patient.ngayvv); console.log('DEBUG - DateUtils.getChecklistDateRange result:', { inputNgayvv: patient.ngayvv, outputTungay: tungay, outputDenngay: denngay }); console.log('DEBUG - Trying both mabn formats:', { originalMabn, mabnWith9898 }); const cacheKey = _makeCacheKey(originalMabn, tungay, denngay); if (options && options.forceRefresh) { _checklistCache.delete(cacheKey); } if (_checklistCache.has(cacheKey)) { console.log('DEBUG - Returning cached/inflight checklist response for', cacheKey); return _checklistCache.get(cacheKey); } const inflight = (async () => { // First try with 9898 suffix const formData = new FormData(); formData.append('mabn', mabnWith9898); formData.append('tungay', tungay); formData.append('denngay', denngay); const response = await fetch('/DanhSachBenhNhan/DSPhieuCCThongTinVaCamKetNhapVien', { method: 'POST', credentials: 'include', body: formData }); const result = await response.json(); console.log('DEBUG - ChecklistService.loadChecklistData API response (with 9898):', result); if (!result.data || result.data.length === 0) { // Fallback without 9898 const fallbackFormData = new FormData(); fallbackFormData.append('mabn', originalMabn); fallbackFormData.append('tungay', tungay); fallbackFormData.append('denngay', denngay); const fallbackResponse = await fetch('/DanhSachBenhNhan/DSPhieuCCThongTinVaCamKetNhapVien', { method: 'POST', credentials: 'include', body: fallbackFormData }); const fallbackResult = await fallbackResponse.json(); console.log('DEBUG - ChecklistService.loadChecklistData API response (original mabn):', fallbackResult); return fallbackResult; } return result; })(); _checklistCache.set(cacheKey, inflight); try { const finalRes = await inflight; // Store resolved promise for subsequent reuse _checklistCache.set(cacheKey, Promise.resolve(finalRes)); return finalRes; } catch (e) { _checklistCache.delete(cacheKey); throw e; } }, /** * Find existing checklist object from response data */ findChecklistObject(responseData) { console.log('DEBUG - findChecklistObject input:', responseData); if (!responseData.data || !Array.isArray(responseData.data) || responseData.data.length === 0) { console.log('DEBUG - No data array or empty array'); return null; } console.log('DEBUG - Searching through', responseData.data.length, 'checklist objects'); for (let i = 0; i < responseData.data.length; i++) { const item = responseData.data[i]; console.log(`DEBUG - Checklist object ${i}:`, item); if (typeof item.hoten === 'string' && item.hoten.trim().endsWith('%')) { console.log('DEBUG - Found matching checklist object with hoten ending with %'); return item; } } console.log('DEBUG - No matching checklist object found'); return null; }, /** * Parse checklist state from checklist object */ parseChecklistState(checklistObj) { let state = {}; if (checklistObj && checklistObj.chuky) { try { state = JSON.parse(checklistObj.chuky); } catch (e) { console.warn('Failed to parse checklist state:', e); state = {}; } } return state; }, /** * Load checklist state for a patient (combination of loadChecklistData and parseChecklistState) */ async loadChecklistState(checklistObj) { try { // If we already have a checklist object, just parse its state if (checklistObj && checklistObj.chuky) { return this.parseChecklistState(checklistObj); } // Otherwise, we need to construct a patient object and load data const patient = { mabn: checklistObj.mabn, mavaovien: checklistObj.mavaovien, ngayvv: checklistObj.tungay // Use tungay as ngayvv for date range calculation }; console.log('DEBUG - checklistService.loadChecklistState patient object:', patient); const responseData = await this.loadChecklistData(patient); const foundChecklistObj = this.findChecklistObject(responseData); if (foundChecklistObj) { return this.parseChecklistState(foundChecklistObj); } return null; } catch (error) { console.warn('Failed to load checklist state:', error); return null; } }, /** * Update checklist state on server */ _locks: new Map(), // mabn -> Promise chain for serialization async updateChecklistState(checklistObj, checklistState, options = {}) { const { enqueueOnOffline = true, signal, ctxId, clientVersion = Date.now() } = options || {}; const mabn = checklistObj && (checklistObj.mabn || checklistObj.MABN || checklistObj.MaBN); // If offline, queue and return if (enqueueOnOffline && typeof navigator !== 'undefined' && navigator && navigator.onLine === false) { SaveQueue.enqueueUpdate(checklistObj, checklistState); return { ok: false, queued: true, clientVersion }; } const send = async () => { const result = await ApiService.updateChecklistData(checklistObj, checklistState, { signal }); const ok = result && (result.Status == 1 || result.isValid); return { ok, queued: false, clientVersion }; }; // Serialize per patient to avoid races if (mabn) { const prev = this._locks.get(mabn) || Promise.resolve(); const next = prev.then(send, send); this._locks.set(mabn, next.catch(() => {})); try { const res = await next; if (res.ok) { // Invalidate cache when saved try { let ngayvv = (checklistObj && (checklistObj.tungay || checklistObj.ngayvv || checklistObj.NgayVV)) || null; if (ngayvv) { const { tungay, denngay } = DateUtils.getChecklistDateRange(ngayvv); const key = _makeCacheKey(mabn, tungay, denngay); _checklistCache.delete(key); } this._invalidateCacheForMabn(mabn); } catch (_) {} } return res; } catch (error) { console.error('Failed to update checklist state:', error); // Network error: queue if allowed if (enqueueOnOffline) { SaveQueue.enqueueUpdate(checklistObj, checklistState); return { ok: false, queued: true, clientVersion }; } return { ok: false, queued: false, clientVersion }; } } else { try { return await send(); } catch (error) { console.error('Failed to update checklist state:', error); if (enqueueOnOffline) { SaveQueue.enqueueUpdate(checklistObj, checklistState); return { ok: false, queued: true, clientVersion }; } return { ok: false, queued: false, clientVersion }; } } }, /** * Create new checklist for patient */ async createNewChecklist(patient) { try { const result = await ApiService.createChecklistForPatient(patient); return result && result.isValid; } catch (error) { console.error('Failed to create new checklist:', error); return false; } } }; module.exports = ChecklistService; },{"../utils/dateUtils":36,"./apiService":26,"./saveQueue":30}],28:[function(require,module,exports){ // patientService.js - Centralized patient data fetching const { fetchToDieuTriData } = require('../pages/page.dashboard.support'); const PatientDataMapper = require('../utils/patientDataMapper'); const LoginHandler = require('../components/loginHandler'); const ChecklistService = require('./checklistService'); const PatientService = { /** * Fetch and process patient data */ async fetchPatientData() { try { const data = await fetchToDieuTriData(); console.log('Dữ liệu ToDieuTri đã được lấy:', data); let arr = Array.isArray(data) ? data : (data && data.data ? data.data : []); if (!arr || arr.length === 0) { return []; } // DEBUG: Check raw tungay format before mapping console.log('DEBUG - Raw tungay format from API:'); arr.slice(0, 3).forEach((item, index) => { console.log(`Raw item ${index + 1} - mabn: ${item.mabn}, tungay: ${item.tungay}, typeof: ${typeof item.tungay}`); }); return PatientDataMapper.mapPatientArray(arr); } catch (error) { console.error('Error fetching patient data:', error); throw error; } }, /** * Enrich patient data with checklist information including surgery data */ async enrichPatientDataWithChecklist(patients) { if (!Array.isArray(patients) || patients.length === 0) { return patients; } console.log('Starting to enrich patient data with checklist information for', patients.length, 'patients'); // DEBUG: Check tungay format in the first few patients console.log('DEBUG - Sample patient data tungay format:'); patients.slice(0, 3).forEach((patient, index) => { console.log(`Patient ${index + 1} - mabn: ${patient.mabn}, tungay: ${patient.tungay}, typeof: ${typeof patient.tungay}`); }); // Process patients in batches to avoid overwhelming the server const batchSize = 5; const enrichedPatients = [...patients]; // Copy array to avoid mutation for (let i = 0; i < patients.length; i += batchSize) { const batch = patients.slice(i, i + batchSize); console.log(`Processing batch ${Math.floor(i/batchSize) + 1}/${Math.ceil(patients.length/batchSize)}`); const batchPromises = batch.map(async (patient, batchIndex) => { const actualIndex = i + batchIndex; try { // Create checklist object for this patient // Use patient's ngayvv (actual admission date) instead of old tungay console.log('DEBUG - Patient ngayvv:', patient.ngayvv); console.log('DEBUG - Background enrichment patient object:', JSON.stringify(patient, null, 2)); const checklistObj = { mabn: patient.mabn, mavaovien: patient.mavaovien, tungay: patient.ngayvv // Use ngayvv (admission date) instead of tungay }; console.log('Loading checklist for patient:', patient.mabn, 'with ngayvv:', patient.ngayvv); // Load checklist state const checklistState = await ChecklistService.loadChecklistState(checklistObj); if (checklistState) { console.log('Checklist state loaded for patient:', patient.mabn, checklistState); // Store checklist state for y lệnh tags enrichedPatients[actualIndex].checklistState = checklistState; // Map surgery data from checklist const surgeryData = PatientDataMapper.mapPhauThuatData(checklistState); if (surgeryData) { console.log('Surgery data mapped for patient:', patient.mabn, surgeryData); enrichedPatients[actualIndex].phauThuatInfo = surgeryData; } else { console.log('No surgery data found for patient:', patient.mabn); } } else { console.log('No checklist state found for patient:', patient.mabn); } } catch (error) { console.warn('Failed to load checklist for patient:', patient.mabn, error); } }); // Wait for current batch to complete before proceeding await Promise.all(batchPromises); // Small delay between batches to be nice to the server if (i + batchSize < patients.length) { await new Promise(resolve => setTimeout(resolve, 100)); } } console.log('Enrichment completed. Patients with surgery info:', enrichedPatients.filter(p => p.phauThuatInfo).length); // Check for celebration animations after enrichment setTimeout(() => { if (typeof window.checkAllCelebrationAnimations === 'function') { window.checkAllCelebrationAnimations(enrichedPatients); } }, 200); return enrichedPatients; }, /** * Get patient data from window.dr_data or fetch from API */ async getPatientData() { // Check if data already exists in window if (window.dr_data && Array.isArray(window.dr_data) && window.dr_data.length > 0) { return window.dr_data; } // Check if fetch function is available if (typeof fetchToDieuTriData !== 'function') { throw new Error('fetchToDieuTriData function not available'); } // Fetch basic patient data from API first (fast) const basicData = await this.fetchPatientData(); // Store basic data immediately for fast initial render window.dr_data = basicData; // Start enrichment in background (don't wait for it) this.enrichPatientDataInBackground(basicData); return basicData; }, /** * Enrich patient data in background without blocking initial render */ async enrichPatientDataInBackground(patients) { console.log('Starting background enrichment for', patients.length, 'patients'); try { const enrichedData = await this.enrichPatientDataWithChecklist(patients); // Update the global data window.dr_data = enrichedData; // Trigger re-render of cards with updated data if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.refreshPatientCards === 'function') { unsafeWindow.refreshPatientCards(enrichedData); } else if (typeof this !== 'undefined' && typeof this.refreshPatientCards === 'function') { this.refreshPatientCards(enrichedData); } else if (typeof globalThis.refreshPatientCards === 'function') { globalThis.refreshPatientCards(enrichedData); } else if (typeof window.refreshPatientCards === 'function') { window.refreshPatientCards(enrichedData); } console.log('Background enrichment completed'); // Check for celebration animations after background enrichment setTimeout(() => { if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.checkAllCelebrationAnimations === 'function') { unsafeWindow.checkAllCelebrationAnimations(enrichedData); } else if (typeof this !== 'undefined' && typeof this.checkAllCelebrationAnimations === 'function') { this.checkAllCelebrationAnimations(enrichedData); } else if (typeof globalThis.checkAllCelebrationAnimations === 'function') { globalThis.checkAllCelebrationAnimations(enrichedData); } else if (typeof window.checkAllCelebrationAnimations === 'function') { window.checkAllCelebrationAnimations(enrichedData); } }, 200); } catch (error) { console.error('Background enrichment failed:', error); } }, /** * Load surgery info for a specific patient (for immediate use) */ async loadPatientSurgeryInfo(patient) { try { const checklistObj = { mabn: patient.mabn, mavaovien: patient.mavaovien, tungay: patient.tungay }; const checklistState = await ChecklistService.loadChecklistState(checklistObj); if (checklistState) { const surgeryData = PatientDataMapper.mapPhauThuatData(checklistState); return surgeryData; } return null; } catch (error) { console.warn('Failed to load surgery info for patient:', patient.mabn, error); return null; } }, /** * Handle patient data loading with error handling */ async loadPatientDataWithErrorHandling() { try { return await this.getPatientData(); } catch (error) { console.error('Failed to load patient data:', error); LoginHandler.handleLoginRequired(); return null; } } }; module.exports = PatientService; },{"../components/loginHandler":11,"../pages/page.dashboard.support":21,"../utils/patientDataMapper":40,"./checklistService":27}],29:[function(require,module,exports){ // reportService.js - Service for generating reports const DateUtils = require('../utils/dateUtils'); const PatientDataMapper = require('../utils/patientDataMapper'); const ChecklistService = require('./checklistService'); const SurgeryUtils = require('../utils/surgeryUtils'); const ReportService = { _escapeHtml(str) { return String(str || '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, // Deprecated: kept for reference; reports now load full checklist state async getPatientTreatmentPlan(mabn, ngayvv) { try { const formData = new FormData(); formData.append('mabn', mabn + 9898); const { tungay, denngay } = DateUtils.getChecklistDateRange(ngayvv); formData.append('tungay', tungay); formData.append('denngay', denngay); const response = await fetch('/DanhSachBenhNhan/DSPhieuCCThongTinVaCamKetNhapVien', { method: 'POST', credentials: 'include', body: formData }); const res = await response.json(); if (res.data && Array.isArray(res.data) && res.data.length > 0) { const obj = res.data[res.data.length - 1]; let state = {}; if (obj && obj.chuky) { try { state = JSON.parse(obj.chuky); } catch (e) { state = {}; } } return state.kehoach || ''; } return ''; } catch (error) { console.error('Error getting treatment plan:', error); return ''; } }, /** * Load checklist state for multiple patients (sorted) */ async getBatchChecklistStates(patients) { const sortedPatients = PatientDataMapper.sortPatients([...patients]); const promises = sortedPatients.map(async (patient) => { try { const res = await ChecklistService.loadChecklistData(patient); const obj = ChecklistService.findChecklistObject(res); return obj ? (ChecklistService.parseChecklistState(obj) || {}) : {}; } catch (e) { console.warn('Failed to load checklist state for', patient?.mabn, e); return {}; } }); const states = await Promise.all(promises); return { sortedPatients, states }; }, /** * Format patient data for report */ formatPatientData(patient, index, state = {}) { const { dob, age } = this.formatDateOfBirth(patient.ngaysinh); const gender = patient.phai === 1 ? 'Nữ' : 'Nam'; const phauThuat = PatientDataMapper.mapPhauThuatData(state); const hxt = (state && typeof state.huongXuTri === 'string') ? state.huongXuTri.trim() : ''; const cdkt = (state && typeof state.chanDoanKemTheo === 'string') ? state.chanDoanKemTheo.trim() : ''; // Build surgery displays similar to dr-card let ppptDisplay = ''; let ngayPtDisplay = ''; if (phauThuat) { const date = phauThuat.ngayPhauThuat || ''; const method = phauThuat.pppt || ''; const info = SurgeryUtils.getSurgeryDateInfo(date); const hpnSuffix = (info && info.postOpDay !== null) ? ` (HPN${info.postOpDay})` : ''; // Show PPPT with HPNx when available ppptDisplay = `${method}${hpnSuffix}`.trim(); // Show only the surgery date (no time) ngayPtDisplay = date; } return { index: index + 1, name: patient.hoten || '', mabn: patient.mabn || '', dob, age, gender, room: patient.teN_PHONG || '', bed: patient.teN_GIUONG || '', diagnosis: `${patient.chandoanvk || ''}${cdkt ? '; ' + cdkt : ''}`, hxt, ppptDisplay, ngayPtDisplay }; }, /** * Format date of birth and calculate age */ formatDateOfBirth(ngaysinh) { let dob = ''; let age = ''; if (ngaysinh) { let d = ngaysinh.split('T')[0]; if (d.includes('-')) { const [y, m, day] = d.split('-'); dob = `${day}/${m}/${y}`; age = (new Date().getFullYear() - parseInt(y, 10)).toString() + 't'; } else if (d.includes('/')) { dob = d; const y = d.split('/')[2]; age = (new Date().getFullYear() - parseInt(y, 10)).toString() + 't'; } } return { dob, age }; }, /** * Generate HTML report content */ generateHTMLReport(patients, states) { let html = ``; // html += `<div style="margin-bottom:10px">Số lượng bệnh nhân hiện có: <b>${patients.length}</b></div>`; patients.forEach((patient, idx) => { const data = this.formatPatientData(patient, idx, states[idx] || {}); html += `<div style='margin-bottom:8px; line-height:1.15;'>`; html += `<h3 style='font-size:1.3em; margin:0 0 4px 0; color:#3277d5'><strong>${data.index}. ${data.name} - ${data.mabn}</strong></h3>`; html += `<div style='margin:2px 0;'><b>DOB</b>: ${data.dob} (${data.age}) - ${data.gender} - ${data.room} - ${data.bed}</div>`; html += `<div style='margin:2px 0;'><b>Chẩn đoán</b>: ${this._escapeHtml(data.diagnosis)}</div>`; if (data.ppptDisplay) html += `<div style='margin:2px 0;'><b>PPPT</b>: ${data.ppptDisplay}</div>`; if (data.ngayPtDisplay) html += `<div style='margin:2px 0;'><b>Ngày PT</b>: ${data.ngayPtDisplay}</div>`; if (data.hxt) html += `<div style='margin:2px 0;'><b>HXT</b>: ${data.hxt}</div>`; html += `</div>`; }); return html; }, /** * Generate HTML for a single patient (no numbering) */ generateSingleHTML(patient, state = {}) { const data = this.formatPatientData(patient, 0, state); let html = ``; html += `<div style='margin-bottom:8px; line-height:1.15;'>`; html += `<h3 style='font-size:1.3em; margin:0 0 4px 0; color:#3277d5'><strong>${data.name} - ${data.mabn}</strong></h3>`; html += `<div style='margin:2px 0;'><b>DOB</b>: ${data.dob} (${data.age}) - ${data.gender} - ${data.room} - ${data.bed}</div>`; html += `<div style='margin:2px 0;'><b>Chẩn đoán</b>: ${this._escapeHtml(data.diagnosis)}</div>`; if (data.ppptDisplay) html += `<div style='margin:2px 0;'><b>PPPT</b>: ${data.ppptDisplay}</div>`; if (data.ngayPtDisplay) html += `<div style='margin:2px 0;'><b>Ngày PT</b>: ${data.ngayPtDisplay}</div>`; if (data.hxt) html += `<div style='margin:2px 0;'><b>HXT</b>: ${data.hxt}</div>`; html += `</div>`; return html; }, /** * Generate plain text report content */ generateTextReport(patients, states) { let report = `BÁO CÁO TRỰC\nSố lượng bệnh nhân hiện có: ${patients.length}\n`; patients.forEach((patient, idx) => { const data = this.formatPatientData(patient, idx, states[idx] || {}); report += `${data.index}. ${data.bed} - ${data.name} - ${data.mabn} - ${data.dob} (${data.age}) - ${data.gender}\n`; report += ` Chẩn đoán: ${data.diagnosis}\n`; if (data.ppptDisplay) report += ` PPPT: ${data.ppptDisplay}\n`; if (data.ngayPtDisplay) report += ` Ngày PT: ${data.ngayPtDisplay}\n`; if (data.hxt) report += ` HXT: ${data.hxt}\n`; }); return report; } , /** * Generate plain text for a single patient (no numbering) */ generateSingleText(patient, state = {}) { const data = this.formatPatientData(patient, 0, state); let report = ''; report += `${data.bed} - ${data.name} - ${data.mabn} - ${data.dob} (${data.age}) - ${data.gender}\n`; report += `Chẩn đoán: ${data.diagnosis}\n`; if (data.ppptDisplay) report += `PPPT: ${data.ppptDisplay}\n`; if (data.ngayPtDisplay) report += `Ngày PT: ${data.ngayPtDisplay}\n`; if (data.hxt) report += `HXT: ${data.hxt}\n`; return report; } }; module.exports = ReportService; },{"../utils/dateUtils":36,"../utils/patientDataMapper":40,"../utils/surgeryUtils":41,"./checklistService":27}],30:[function(require,module,exports){ // saveQueue.js - Offline queue for checklist saves const QUEUE_KEY = 'dr_save_queue_v1'; function loadQueue() { try { const raw = localStorage.getItem(QUEUE_KEY); const arr = raw ? JSON.parse(raw) : []; return Array.isArray(arr) ? arr : []; } catch (_) { return []; } } function saveQueue(arr) { try { localStorage.setItem(QUEUE_KEY, JSON.stringify(arr)); } catch (_) {} } // Keep only the latest item per mabn (dedupe) function upsertByMabn(queue, item) { const idx = queue.findIndex(q => q.mabn === item.mabn); if (idx >= 0) queue[idx] = item; else queue.push(item); } const SaveQueue = { enqueueUpdate(checklistObj, checklistState) { const mabn = (checklistObj && (checklistObj.mabn || checklistObj.MABN || checklistObj.MaBN)) || ''; const item = { id: `${mabn}:${Date.now()}`, mabn, type: 'updateChecklist', payload: { checklistObj, checklistState }, createdAt: Date.now() }; const q = loadQueue(); upsertByMabn(q, item); saveQueue(q); return item.id; }, async drain(processor) { // processor: async ({ checklistObj, checklistState }) => boolean const q = loadQueue(); if (!q.length) return 0; let successCount = 0; const rest = []; for (const item of q) { try { const ok = await processor(item.payload); if (ok) successCount++; else rest.push(item); } catch (_) { rest.push(item); } } saveQueue(rest); return successCount; }, size() { return loadQueue().length; }, purge(mabn) { const q = loadQueue().filter(i => i.mabn !== mabn); saveQueue(q); } }; module.exports = SaveQueue; },{}],31:[function(require,module,exports){ // settingsService.js - Manage settings stored in a checklist-like phiếu using doctor name as mabn const ApiService = require('./apiService'); const { getSelectedKhoa } = require('../utils/khoaUtils'); const SettingsService = { async fetchDoctorName() { try { const body = new URLSearchParams(); body.set('FilterProperty', ''); body.set('FilterBy', ''); body.set('Page', '1'); body.set('PageSize', '20'); body.set('OrderProperty', ''); body.set('OrderBy', ''); body.set('id', ''); body.set('_key', 'change-pin-chung-thu-so'); const response = await fetch('/sp-admin/change-pin-chung-thu-so/Form', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'Accept': '*/*', 'X-Requested-With': 'XMLHttpRequest' }, body }); const htmlText = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(htmlText, 'text/html'); const input = doc.querySelector('#HoTen'); let name = ''; if (input) { name = (input.value || input.getAttribute('value') || '').trim(); } return name; } catch (e) { console.error('Failed to fetch doctor name:', e); return ''; } }, async loadSettingsPhieu(doctorName) { // Use DSPhieu API with doctorName as mabn const formData = new FormData(); formData.append('mabn', doctorName); // very wide range formData.append('tungay', '01/01/1001 01:01'); formData.append('denngay', '01/01/3001 01:01'); const resp = await fetch('/DanhSachBenhNhan/DSPhieuCCThongTinVaCamKetNhapVien', { method: 'POST', credentials: 'include', body: formData }); const result = await resp.json(); const data = (result && result.data) || []; // Pick first item that looks like our settings (hoten endsWith % and mabn==doctorName) const found = data.find(item => item && item.mabn === doctorName && typeof item.hoten === 'string' && item.hoten.endsWith('%')) || null; return found; }, parseSettingsState(checklistObj) { if (!checklistObj || !checklistObj.chuky) return {}; try { const parsed = JSON.parse(checklistObj.chuky); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } }, async createSettingsPhieu(doctorName) { // Reuse CreateAjax endpoint with doctorName as mabn const formData = new FormData(); formData.append('status', '1'); formData.append('thebaohiemyte', 'Không'); formData.append('chuky', '{}'); formData.append('khac', '--*--'); formData.append('khu', '1'); formData.append('mabn', doctorName); formData.append('bieumauid', '027'); formData.append('makp', getSelectedKhoa('551')); formData.append('__model', 'TAH.Entity.Model.PHIEUCCTHONGTINVACAMKETNHAPVIEN.ERM_PHIEUCCTHONGTINVACAMKETNHAPVIEN'); formData.append('actiontype', ''); // Mark with name% so it can be identified and matched by endsWith('%') formData.append('hoten', `${doctorName}%`); formData.append('ngaysinh', '10/10/1999'); formData.append('gioitinh', 'Nam'); const response = await fetch('/ERM_PHIEUCCTHONGTINVACAMKETNHAPVIEN/CreateAjax', { method: 'POST', credentials: 'include', body: formData }); return response.json(); }, async updateSettingsState(oldData, settingsState) { try { const res = await ApiService.updateChecklistData(oldData, settingsState); return res && (res.Status == 1 || res.isValid); } catch (e) { console.error('Failed to update settings state:', e); return false; } }, getDefaultSettings() { return { danDoRaVien: [ 'Uống thuốc đúng toa được dặn', 'Tái khám đúng hẹn', 'Liên hệ khi có dấu hiệu bất thường' ] }; }, async getOrCreateSettings() { const doctorName = await this.fetchDoctorName(); if (!doctorName) { return { doctorName: '', checklistObj: null, settings: this.getDefaultSettings() }; } let checklistObj = await this.loadSettingsPhieu(doctorName); if (!checklistObj) { const created = await this.createSettingsPhieu(doctorName); if (created && created.isValid && created.data) { // Some CreateAjax returns full object, some just flags; re-read list to get object checklistObj = await this.loadSettingsPhieu(doctorName); } } const settings = checklistObj ? this.parseSettingsState(checklistObj) : this.getDefaultSettings(); if (!settings.danDoRaVien) settings.danDoRaVien = this.getDefaultSettings().danDoRaVien; return { doctorName, checklistObj, settings }; } }; module.exports = SettingsService; },{"../utils/khoaUtils":39,"./apiService":26}],32:[function(require,module,exports){ // surgeonSettingsService.js - Store selected surgeons per khoa using checklist-like records const ApiService = require('./apiService'); function makeKeyForKhoa(khoaId) { return `${String(khoaId)}%h991h otm.dsbacsi`; } async function loadRecordForKhoa(khoaId) { const key = makeKeyForKhoa(khoaId); const formData = new FormData(); formData.append('mabn', key); // Use a very wide range to ensure the special record is returned formData.append('tungay', '01/01/1001 01:01'); formData.append('denngay', '01/01/3001 01:01'); const resp = await fetch('/DanhSachBenhNhan/DSPhieuCCThongTinVaCamKetNhapVien', { method: 'POST', credentials: 'include', body: formData }); const result = await resp.json(); const data = (result && result.data) || []; const found = data.find(item => item && item.mabn === key && typeof item.hoten === 'string' && item.hoten.endsWith('%')) || null; return found; } async function createRecordForKhoa(khoaId) { const key = makeKeyForKhoa(khoaId); const formData = new FormData(); formData.append('status', '1'); formData.append('thebaohiemyte', 'Không'); // Initialize with empty list formData.append('chuky', JSON.stringify({ otm: { dsbacsi: [] } })); formData.append('khac', '--*--'); formData.append('khu', '1'); formData.append('mabn', key); formData.append('bieumauid', '027'); // Use a fixed marker date as per convention formData.append('makp', '551'); formData.append('__model', 'TAH.Entity.Model.PHIEUCCTHONGTINVACAMKETNHAPVIEN.ERM_PHIEUCCTHONGTINVACAMKETNHAPVIEN'); formData.append('actiontype', ''); formData.append('hoten', `${key}%`); formData.append('ngaysinh', '10/10/1010'); formData.append('gioitinh', 'Nam'); const response = await fetch('/ERM_PHIEUCCTHONGTINVACAMKETNHAPVIEN/CreateAjax', { method: 'POST', credentials: 'include', body: formData }); return response.json(); } function parseState(obj) { if (!obj || !obj.chuky) return { otm: { dsbacsi: [] } }; try { const parsed = JSON.parse(obj.chuky); if (parsed && parsed.otm && Array.isArray(parsed.otm.dsbacsi)) return parsed; // normalize const arr = parsed && (parsed.dsbacsi || parsed.surgeons || []); return { otm: { dsbacsi: Array.isArray(arr) ? arr : [] } }; } catch (_) { return { otm: { dsbacsi: [] } }; } } const SurgeonSettingsService = { makeKeyForKhoa, async getOrCreateRecord(khoaId) { let obj = await loadRecordForKhoa(khoaId); if (!obj) { const created = await createRecordForKhoa(khoaId); if (created && (created.isValid || created.Status == 1)) { obj = await loadRecordForKhoa(khoaId); } } return obj; }, async loadSurgeonList(khoaId) { const obj = await loadRecordForKhoa(khoaId); const state = parseState(obj); return { list: state.otm.dsbacsi || [], obj }; }, async saveSurgeonList(khoaId, fullnames) { const obj = await this.getOrCreateRecord(khoaId); if (!obj) return { ok: false }; const next = { otm: { dsbacsi: Array.from(new Set((fullnames || []).map(s => String(s).trim()).filter(Boolean))) } }; const res = await ApiService.updateChecklistData(obj, next); const ok = res && (res.Status == 1 || res.isValid); return { ok }; } }; module.exports = SurgeonSettingsService; },{"./apiService":26}],33:[function(require,module,exports){ // Top-level compatibility shim for legacy imports module.exports = require('./pages/page.settings-open-world'); // Top-level compatibility shim for legacy imports // This allows requiring '../settings-open-world' from files inside src/pages module.exports = require('./pages/page.settings-open-world'); },{"./pages/page.settings-open-world":23}],34:[function(require,module,exports){ // Common utility functions (date formatting, age calculation, etc.) const Utils = { _normalizeDateInput(dateInput) { if (!dateInput) return { dateObj: null, timeStr: null }; let effectiveDateObj = null; let extractedTimeStr = null; if (dateInput instanceof Date) { effectiveDateObj = dateInput; extractedTimeStr = `${String(effectiveDateObj.getHours()).padStart(2, '0')}:${String(effectiveDateObj.getMinutes()).padStart(2, '0')}`; } else if (typeof dateInput === 'string') { let datePart = dateInput; let timePart = null; if (datePart.includes('T')) { const parts = datePart.split('T'); datePart = parts[0]; if (parts[1]) timePart = parts[1].substring(0, 5); } else if (datePart.includes(' ')) { const parts = datePart.split(' '); datePart = parts[0]; if (parts[1]) timePart = parts[1].substring(0, 5); } extractedTimeStr = timePart; if (/\d{4}-\d{2}-\d{2}/.test(datePart)) { effectiveDateObj = new Date(datePart + 'T00:00:00'); } else if (/\d{2}\/\d{2}\/\d{4}/.test(datePart)) { const [d, m, y] = datePart.split('/'); effectiveDateObj = new Date(`${y}-${m}-${d}T00:00:00`); } else { const potentialDate = new Date(dateInput); if (!isNaN(potentialDate.getTime())) { effectiveDateObj = potentialDate; if (!extractedTimeStr && (dateInput.includes('T') || dateInput.includes(' ')) && dateInput.includes(':')) { extractedTimeStr = `${String(effectiveDateObj.getHours()).padStart(2, '0')}:${String(effectiveDateObj.getMinutes()).padStart(2, '0')}`; } } } if (effectiveDateObj && isNaN(effectiveDateObj.getTime())) { effectiveDateObj = null; } } return { dateObj: effectiveDateObj, timeStr: extractedTimeStr }; }, calculateAge(dateString) { const { dateObj: birth } = this._normalizeDateInput(dateString); if (!birth) return ''; const today = new Date(); let age = today.getFullYear() - birth.getFullYear(); const m = today.getMonth() - birth.getMonth(); if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) { age--; } return age; }, formatDate(date) { const { dateObj } = this._normalizeDateInput(date); if (!dateObj) return ''; const day = String(dateObj.getDate()).padStart(2, '0'); const month = String(dateObj.getMonth() + 1).padStart(2, '0'); const year = dateObj.getFullYear(); return `${day}/${month}/${year}`; }, formatDateForApi(date) { const { dateObj, timeStr } = this._normalizeDateInput(date); if (!dateObj) return ''; const day = String(dateObj.getDate()).padStart(2, '0'); const month = String(dateObj.getMonth() + 1).padStart(2, '0'); const year = dateObj.getFullYear(); const finalTime = timeStr || '00:00'; return `${month}/${day}/${year} ${finalTime}`; }, pad(n, width = 2) { n = n + ''; return n.length >= width ? n : new Array(width - n.length + 1).join('0') + n; }, getQueryParam(name) { const url = new URL(window.location.href); return url.searchParams.get(name); }, copyToClipboard(text) { if (navigator.clipboard) { navigator.clipboard.writeText(text); } else { const textarea = document.createElement('textarea'); textarea.value = text; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } } }; module.exports = Utils; },{}],35:[function(require,module,exports){ // checklistUtils.js - Checklist-related utility functions const { showToast, copyToClipboard } = require('./uiUtils'); const ChecklistService = require('../services/checklistService'); /** * Create checklist item HTML with special actions * @param {string} itemText - Item text * @param {string} id - Item ID * @param {boolean} isChecked - Whether item is checked * @param {object} patient - Patient data * @returns {string} - HTML string */ function createChecklistItemHTML(itemText, id, isChecked, patient) { const baseHTML = `<label style="display:flex;align-items:center;gap:8px;"><input type="checkbox" id="${id}" ${isChecked ? 'checked' : ''}>${itemText}</label>`; // Add special clickable items without checkbox for certain items if (itemText === 'Mở HSBA v2') { return ` <div style="display:flex;align-items:center;gap:8px;padding:8px;background:#e3f2fd;border-radius:4px;cursor:pointer;transition:background-color 0.2s;" onclick="openHSBAV2('${patient.mabn}')" onmouseover="this.style.backgroundColor='#bbdefb'" onmouseout="this.style.backgroundColor='#e3f2fd'"> <span style="color:#1976d2;font-weight:500;">🔗 ${itemText}</span> <span style="margin-left:auto;color:#1976d2;font-size:0.8em;">👆 Click để mở</span> </div> `; } else if (itemText === 'Mở trang dặn dò') { return ` <div style="display:flex;align-items:center;gap:8px;padding:8px;background:#fff3e0;border-radius:4px;cursor:pointer;transition:background-color 0.2s;" onclick="window.open('https://hoaiump.notion.site/D-N-D-RA-VI-N-21025280dcee804c971bea55557264b9', '_blank')" onmouseover="this.style.backgroundColor='#ffe0b2'" onmouseout="this.style.backgroundColor='#fff3e0'"> <span style="color:#f57c00;font-weight:500;">📋 ${itemText}</span> <span style="margin-left:auto;color:#f57c00;font-size:0.8em;">👆 Click để mở</span> </div> `; } else if (itemText === 'Thực hiện y lệnh thuốc đã dự trù') { const key = `xuatvien_${itemText}`; const isCompleted = window.checklistState && window.checklistState[key] || false; return ` <div style="display:flex;align-items:center;gap:8px;padding:8px;background:${isCompleted ? '#e8f5e8' : '#f3e5f5'};border-radius:4px;cursor:pointer;transition:background-color 0.2s;border:${isCompleted ? '2px solid #4caf50' : '1px solid #9c27b0'};" onclick="copyYLenhText('${itemText}', '${id}', '${patient.mabn}')" onmouseover="this.style.backgroundColor='${isCompleted ? '#dcedc8' : '#e1bee7'}'" onmouseout="this.style.backgroundColor='${isCompleted ? '#e8f5e8' : '#f3e5f5'}'"> <span style="color:${isCompleted ? '#2e7d32' : '#7b1fa2'};font-weight:500;">${isCompleted ? '✅' : '📋'} ${itemText}</span> <span style="margin-left:auto;color:${isCompleted ? '#2e7d32' : '#7b1fa2'};font-size:0.8em;">${isCompleted ? '✅ Đã copy' : '👆 Click để copy'}</span> </div> `; } return baseHTML; } /** * Copy y lệnh text and mark as completed * @param {string} text - Text to copy * @param {string} id - Item ID * @param {string} mabn - Patient MABN */ async function copyYLenhText(text, id, mabn) { const success = await copyToClipboard(text); if (success) { // Mark as completed in checklist state if (!window.checklistState) { window.checklistState = {}; } const key = `xuatvien_${text}`; window.checklistState[key] = true; // Save to server if (window.checklistObj) { const res = await ChecklistService.updateChecklistState(window.checklistObj, window.checklistState, { enqueueOnOffline: true, ctxId: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.id), signal: (window.dr_sidebar_ctx && window.dr_sidebar_ctx.signal) }); if (!res || (!res.ok && !res.queued)) { console.error('Lưu checklist thất bại!'); } } // Show success toast showToast(`📋 Đã copy: "${text}"`, { background: '#4caf50', duration: 2500 }); // Re-render the checklist to show completed state setTimeout(() => { const xuatvienList = document.querySelector('#checklist-xuatvien'); if (xuatvienList) { const patient = (typeof dr_data !== 'undefined' && dr_data) ? dr_data.find(p => p.mabn === mabn) : null; if (patient) { // Try different global scopes for renderChecklistXuatVien function const renderFn = globalThis.renderChecklistXuatVien || (typeof unsafeWindow !== 'undefined' && unsafeWindow.renderChecklistXuatVien) || (typeof this !== 'undefined' && this.renderChecklistXuatVien) || window.renderChecklistXuatVien; if (renderFn) { renderFn(xuatvienList, patient); } } } }, 100); } else { showToast('❌ Không thể copy vào clipboard', { background: '#f44336', duration: 2000 }); } } /** * Check celebration for card * @param {HTMLElement} card - Card element * @param {object} patient - Patient data */ function checkCelebrationForCard(card, patient) { if (!patient || !patient.checklistState || !patient.checklistState.yLenhLog) { card.classList.remove('xuatvienanimation'); return; } // Check if any entries contain "xuất viện" const dischargeEntries = patient.checklistState.yLenhLog.filter(entry => { return entry.content && entry.content.toLowerCase().includes('xuất viện'); }); if (dischargeEntries.length > 0) { card.classList.add('xuatvienanimation'); } else { card.classList.remove('xuatvienanimation'); } } /** * Check celebration animations for all cards * @param {Array} enrichedPatients - Patient data array */ function checkAllCelebrationAnimations(enrichedPatients) { const cards = document.querySelectorAll('.dr-card'); cards.forEach((card) => { // Get patient MABN from card const cardTitle = card.querySelector('h2'); if (!cardTitle) return; const cardText = cardTitle.textContent; const mabnMatch = cardText.match(/(\d{8,})/); // Find MABN pattern if (!mabnMatch) return; const mabn = mabnMatch[1]; // Find corresponding patient in enriched data const patient = enrichedPatients.find(p => p.mabn === mabn); if (patient) { checkCelebrationForCard(card, patient); } }); } module.exports = { createChecklistItemHTML, copyYLenhText, checkCelebrationForCard, checkAllCelebrationAnimations }; },{"../services/checklistService":27,"./uiUtils":43}],36:[function(require,module,exports){ // dateUtils.js - Centralized date handling utilities const DateUtils = { /** * Convert Vietnamese date format (dd/mm/yyyy) to US format (mm/dd/yyyy) * Also handles cases where input is already in mm/dd/yyyy format */ convertToUSFormat(admitDate) { if (!admitDate) { const now = new Date(); const dd = String(now.getDate()).padStart(2, '0'); const mm = String(now.getMonth() + 1).padStart(2, '0'); const yyyy = now.getFullYear(); return `${mm}/${dd}/${yyyy} 00:00`; } // DEBUG: Log input format console.log('DEBUG - DateUtils.convertToUSFormat input:', admitDate); if (/^\d{2}\/\d{2}\/\d{4}/.test(admitDate)) { const [part1, part2, yearAndTime] = admitDate.split('/'); const [year, time] = yearAndTime.split(' '); // Try to determine if it's dd/mm/yyyy or mm/dd/yyyy // If part1 > 12, it must be dd/mm/yyyy format // If part2 > 12, it must be mm/dd/yyyy format const num1 = parseInt(part1); const num2 = parseInt(part2); let month, day; if (num1 > 12) { // part1 is day, part2 is month (dd/mm/yyyy format) day = part1; month = part2; console.log('DEBUG - Detected dd/mm/yyyy format'); } else if (num2 > 12) { // part1 is month, part2 is day (mm/dd/yyyy format - already US format) month = part1; day = part2; console.log('DEBUG - Detected mm/dd/yyyy format (already US format)'); } else { // Both numbers <= 12, assume Vietnamese format (dd/mm/yyyy) day = part1; month = part2; console.log('DEBUG - Ambiguous format, assuming dd/mm/yyyy'); } const result = `${month}/${day}/${year} ${time || '00:00'}`; console.log('DEBUG - DateUtils.convertToUSFormat output:', result); return result; } console.log('DEBUG - DateUtils.convertToUSFormat: returning input as-is'); return admitDate; }, /** * Calculate date range for checklist (from admit date to +30 days) */ getChecklistDateRange(admitDate) { const tungay = this.convertToUSFormat(admitDate); const [admitMonth, admitDay, admitYearAndTime] = tungay.split('/'); const [admitYear, admitTime] = admitYearAndTime.split(' '); const tungayDate = new Date(`${admitYear}-${admitMonth}-${admitDay}T${admitTime || '00:00'}`); const denngayDate = new Date(tungayDate.getTime() + 30 * 24 * 60 * 60 * 1000); const dd = String(denngayDate.getDate()).padStart(2, '0'); const mm = String(denngayDate.getMonth() + 1).padStart(2, '0'); const yyyy = denngayDate.getFullYear(); const denngay = `${mm}/${dd}/${yyyy} 23:59`; return { tungay, denngay }; } }; module.exports = DateUtils; },{}],37:[function(require,module,exports){ // domUpdaters.js - shared UI update helpers for both card and list rows const { createYLenhTags, updateMedsDoneBadge } = require('./tagUtils'); const { addSurgeryStatusIcon, formatSurgeryInfo } = require('./surgeryUtils'); const { escapeHtml } = require('./htmlUtils'); function findPatientElement(mabn) { if (!mabn) return null; return ( document.querySelector(`.dr-card[data-mabn="${mabn}"]`) || document.querySelector(`.dr-list-row[data-mabn="${mabn}"]`) ); } function updateHXT(patient) { try { const el = findPatientElement(patient && patient.mabn); if (!el) return; const hxtText = (patient.checklistState && patient.checklistState.huongXuTri) ? String(patient.checklistState.huongXuTri).trim() : ''; const old = el.querySelector('.dr-hxt-block'); if (old) old.remove(); if (!hxtText) return; const div = document.createElement('div'); div.className = 'dr-value dr-hxt-block'; div.innerHTML = `<span class="dr-label"><b>HXT:</b></span> ${escapeHtml(hxtText)}`; if (el.classList.contains('dr-card')) { const ptInfoEl = el.querySelector('.dr-pt-info'); const cdEl = el.querySelector('.dr-diagnosis-line'); if (ptInfoEl) ptInfoEl.insertAdjacentElement('afterend', div); else if (cdEl) cdEl.insertAdjacentElement('afterend', div); else el.insertAdjacentElement('afterbegin', div); } else { const dxEl = el.querySelector('.dr-list-dx'); if (dxEl) dxEl.insertAdjacentElement('afterend', div); else el.insertAdjacentElement('afterbegin', div); } } catch (_) {} } /** * Compose diagnosis strings consistently. * Returns baseText (primary diagnosis + optional ICD in parentheses) * and combinedHtml which appends CDKT (clamped span) when present. */ function composeDiagnosis(patient) { const icdSuffix = patient && patient.maicdvk ? ` (${String(patient.maicdvk).trim()})` : ''; const baseText = `${(patient && patient.chandoanvk) ? patient.chandoanvk : ''}${icdSuffix}`; const cdktText = (patient && patient.checklistState && typeof patient.checklistState.chanDoanKemTheo === 'string') ? patient.checklistState.chanDoanKemTheo.trim() : ''; const combinedHtml = `${baseText}${cdktText ? '; <span class="dr-cdkt-clamp">' + escapeHtml(cdktText) + '</span>' : ''}`; return { baseText, cdktText, combinedHtml }; } function updateCDKT(patient) { try { const el = findPatientElement(patient && patient.mabn); if (!el) return; const diagnosisLine = el.querySelector('.dr-diagnosis-line'); if (!diagnosisLine) return; const { baseText, cdktText, combinedHtml } = composeDiagnosis(patient); diagnosisLine.dataset.baseCd = baseText; diagnosisLine.dataset.cdkt = cdktText; diagnosisLine.innerHTML = `<span class="dr-label">Chẩn đoán:</span> ${combinedHtml}`; } catch (_) {} } function updateTagsAndMedsBadge(containerEl, patient) { try { if (!containerEl || !patient) return; const existingTags = containerEl.querySelector('.ylenh-tags'); if (existingTags) existingTags.remove(); const tagsHtml = createYLenhTags(patient); if (tagsHtml) { let inserted = false; if (containerEl.classList.contains('dr-card')) { const btnGroup = containerEl.querySelector('.dr-action-buttons'); if (btnGroup) { btnGroup.insertAdjacentHTML('beforebegin', tagsHtml); inserted = true; } } if (!inserted) { const left = containerEl.querySelector(':scope > div'); if (left) left.insertAdjacentHTML('beforeend', tagsHtml); else containerEl.insertAdjacentHTML('beforeend', tagsHtml); } } updateMedsDoneBadge(containerEl, patient); } catch (_) {} } function updateSurgeryInfo(containerEl, patient) { try { if (!containerEl) return; const ptInfoContainer = containerEl.querySelector('.dr-pt-info'); if (!ptInfoContainer) return; const formattedPtInfo = formatSurgeryInfo(patient); const tempDiv = document.createElement('div'); tempDiv.innerHTML = formattedPtInfo; const inner = tempDiv.querySelector('.dr-pt-info'); if (inner) ptInfoContainer.innerHTML = inner.innerHTML; } catch (_) {} } function updateSurgeryIcon(containerEl, patient) { try { addSurgeryStatusIcon(containerEl, patient); } catch (_) {} } module.exports = { updateHXT, updateCDKT, updateTagsAndMedsBadge, updateSurgeryInfo, updateSurgeryIcon, findPatientElement, composeDiagnosis, }; },{"./htmlUtils":38,"./surgeryUtils":41,"./tagUtils":42}],38:[function(require,module,exports){ // htmlUtils.js - HTML/text helpers function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\n/g, '<br/>'); } module.exports = { escapeHtml }; },{}],39:[function(require,module,exports){ // khoaUtils.js - central helpers for selected khoa id function getSelectedKhoa(defaultValue = '551') { try { const ls = window.localStorage; return (ls.getItem('bsnt_khoa_dashboard') || defaultValue); } catch (_) { return defaultValue; } } module.exports = { getSelectedKhoa }; },{}],40:[function(require,module,exports){ // patientDataMapper.js - Centralized patient data mapping const PatientDataMapper = { /** * Map raw patient data to standardized format */ mapPatientData(item) { return { teN_GIUONG: item.teN_GIUONG, teN_PHONG: item.teN_PHONG, teN_TANG: item.teN_TANG, teN_TOANHA: item.teN_TOANHA, mabn: item.mabn, hoten: item.hoten, ngaysinh: item.ngaysinh, phai: item.phai, mavaovien: item.mavaovien, chandoanvk: item.chandoanvk, maicdvk: item.maicdvk, kehoach: item.kehoach, ngayvv: item.ngayvv, maql: item.maql, tungay: item.tungay }; }, /** * Map array of patient data */ mapPatientArray(dataArray) { if (!Array.isArray(dataArray)) { return []; } return dataArray.map(item => this.mapPatientData(item)); }, /** * Determine if room should have white card styling */ isWhiteCard(room) { if (!room) return false; return /^(Phòng )?(214|215|216)$/i.test(room) || /214|215|216/.test(room); }, /** * Format room info - extract only room number * "Phòng 402" -> "402" */ formatRoom(roomText) { if (!roomText) return ''; // Remove "Phòng " prefix and keep only the number/text return roomText.replace(/^Phòng\s*/i, '').trim(); }, /** * Format bed info - extract only bed letter/name * "Giường A" -> "A" * "G215-A" -> "A" */ formatBed(bedText) { if (!bedText) return ''; // Remove "Giường " prefix first let cleaned = bedText.replace(/^Giường\s*/i, '').trim(); // If there's a dash, take only the part after the last dash if (cleaned.includes('-')) { const parts = cleaned.split('-'); return parts[parts.length - 1].trim(); } // Otherwise return the cleaned text return cleaned; }, /** * Format floor info - extract only floor number * "Tầng 4" -> "4" */ formatFloor(floorText) { if (!floorText) return ''; // Remove "Tầng " prefix and keep only the number return floorText.replace(/^Tầng\s*/i, '').trim(); }, /** * Format building info - extract only building letter/name * "Tòa C" -> "C" */ formatBuilding(buildingText) { if (!buildingText) return ''; // Remove "Tòa " prefix and keep only the letter/name return buildingText.replace(/^Tòa\s*/i, '').trim(); }, /** * Format complete room location info * Returns formatted string: "402 A - 4 C" */ formatRoomLocation(room, bed, floor, building) { const formattedRoom = this.formatRoom(room); const formattedBed = this.formatBed(bed); const formattedFloor = this.formatFloor(floor); const formattedBuilding = this.formatBuilding(building); let location = ''; // Add room and bed if (formattedRoom) { location += formattedRoom; if (formattedBed) { location += `${formattedBed}`; } } // Add separator if we have floor or building if ((formattedFloor || formattedBuilding) && location) { location += ' - '; } // Add floor and building if (formattedFloor) { location += "Tòa " + formattedFloor; if (formattedBuilding) { location += `${formattedBuilding}`; } } else if (formattedBuilding) { location += formattedBuilding; } return location; }, /** * Extract numeric value from text for sorting * "402" -> 402, "A" -> 0, "4C" -> 4 */ extractNumericValue(text) { if (!text) return 0; const match = text.toString().match(/\d+/); return match ? parseInt(match[0], 10) : 0; }, /** * Extract alpha value from text for sorting * "402A" -> "A", "G215-B" -> "B", "4" -> "" */ extractAlphaValue(text) { if (!text) return ''; // Remove numbers and special characters, keep only letters const alpha = text.toString().replace(/[^A-Za-z]/g, ''); return alpha.toUpperCase(); }, /** * Sort patients by Building -> Floor -> Room -> Bed (all ascending) */ sortPatients(patients) { return patients.sort((a, b) => { // 1. Sort by Building (Tòa nhà) const buildingA = this.extractAlphaValue(this.formatBuilding(a.teN_TOANHA)); const buildingB = this.extractAlphaValue(this.formatBuilding(b.teN_TOANHA)); if (buildingA !== buildingB) { return buildingA.localeCompare(buildingB); } // 2. Sort by Floor (Tầng) const floorA = this.extractNumericValue(this.formatFloor(a.teN_TANG)); const floorB = this.extractNumericValue(this.formatFloor(b.teN_TANG)); if (floorA !== floorB) { return floorA - floorB; } // 3. Sort by Room number (Phòng) const roomA = this.extractNumericValue(this.formatRoom(a.teN_PHONG)); const roomB = this.extractNumericValue(this.formatRoom(b.teN_PHONG)); if (roomA !== roomB) { return roomA - roomB; } // 4. Sort by Bed (Giường) // First by numeric part, then by alpha part const bedA = this.formatBed(a.teN_GIUONG); const bedB = this.formatBed(b.teN_GIUONG); const bedNumA = this.extractNumericValue(bedA); const bedNumB = this.extractNumericValue(bedB); if (bedNumA !== bedNumB) { return bedNumA - bedNumB; } const bedAlphaA = this.extractAlphaValue(bedA); const bedAlphaB = this.extractAlphaValue(bedB); return bedAlphaA.localeCompare(bedAlphaB); }); }, /** * Map surgery data from checklist state */ mapPhauThuatData(checklistData) { if (!checklistData || !checklistData.phauThuatLog) { return null; } const logs = checklistData.phauThuatLog; if (!Array.isArray(logs) || logs.length === 0) { return null; } // Get the latest surgery record (first one since newest is first) const latestSurgery = logs[0]; return { ngayPhauThuat: latestSurgery.date || '', // Already in dd/mm/yyyy format gioPhauThuat: latestSurgery.time || '', // Already in HH:MM format pppt: latestSurgery.method || '', bacSi: latestSurgery.doctors || '', timestamp: latestSurgery.id || new Date().getTime() }; }, /** * Check if patient has surgery data */ hasPhauThuatData(checklistData) { if (!checklistData || !checklistData.phauThuatLog) { return false; } const logs = checklistData.phauThuatLog; return Array.isArray(logs) && logs.length > 0; } }; module.exports = PatientDataMapper; },{}],41:[function(require,module,exports){ // surgeryUtils.js - Surgery-related utility functions /** * Parse surgery date and get detailed info * @param {string} surgeryDateStr - Surgery date string * @returns {object|null} - Surgery date info */ function getSurgeryDateInfo(surgeryDateStr) { if (!surgeryDateStr) return null; // Extract date from surgery date string (format: dd/mm/yyyy or yyyy-mm-dd) let surgeryDate; if (surgeryDateStr.includes('/')) { // Format: dd/mm/yyyy const [day, month, year] = surgeryDateStr.split('/'); surgeryDate = new Date(year, month - 1, day); } else if (surgeryDateStr.includes('-')) { // Format: yyyy-mm-dd surgeryDate = new Date(surgeryDateStr); } else { return null; } // Get today's date (without time) const today = new Date(); today.setHours(0, 0, 0, 0); // Set surgery date to start of day surgeryDate.setHours(0, 0, 0, 0); // Calculate days difference const timeDiff = today.getTime() - surgeryDate.getTime(); const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24)); // Determine status let status; if (daysDiff > 0) { status = 'past'; // Before today } else if (daysDiff === 0) { status = 'today'; // Today } else { status = 'future'; // Tomorrow or later } return { status: status, daysDiff: daysDiff, postOpDay: daysDiff >= 0 ? daysDiff : null // Only calculate for past/today surgeries }; } /** * Parse surgery date and compare with today (backward compatibility) * @param {string} surgeryDateStr - Surgery date string * @returns {string|null} - Surgery status */ function getSurgeryDateStatus(surgeryDateStr) { const info = getSurgeryDateInfo(surgeryDateStr); return info ? info.status : null; } /** * Add surgery status icon to card * @param {HTMLElement} card - Patient card element * @param {object} item - Patient item */ function addSurgeryStatusIcon(card, item) { // Remove existing status icon if any const existingIcon = card.querySelector('.dr-surgery-status-icon'); if (existingIcon) { existingIcon.remove(); } // Do not show icon for list view rows try { if (card && card.classList && card.classList.contains('dr-list-row')) return; } catch (_) {} // Get surgery date from item let surgeryDate = null; if (item.phauThuatInfo) { // From new format surgeryDate = item.phauThuatInfo.date || item.phauThuatInfo.ngayPhauThuat; } else if (item.checklistState && item.checklistState.phauThuatLog && item.checklistState.phauThuatLog.length > 0) { // From checklist log (latest surgery) surgeryDate = item.checklistState.phauThuatLog[0].date; } const surgeryInfo = getSurgeryDateInfo(surgeryDate); if (!surgeryInfo) return; // Create icon element const iconDiv = document.createElement('div'); iconDiv.className = 'dr-surgery-status-icon'; iconDiv.style.cssText = ` position: absolute; top: -4px; left: -4px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-size: 20px; z-index: 10; pointer-events: none; `; // Set icon and title based on status switch (surgeryInfo.status) { case 'past': iconDiv.textContent = '⬅️'; iconDiv.title = `Phẫu thuật đã qua - HPN${surgeryInfo.postOpDay}`; break; case 'today': iconDiv.textContent = '⏸️'; iconDiv.title = 'Hôm nay PT'; break; case 'future': const daysUntil = Math.abs(surgeryInfo.daysDiff); iconDiv.textContent = '➡️'; if (daysUntil === 1) { iconDiv.title = 'Ngày mai'; } else if (daysUntil === 2) { iconDiv.title = 'Ngày mốt PT'; } else { iconDiv.title = `Còn ${daysUntil} ngày nữa PT`; } break; } // Add to card card.style.position = 'relative'; card.appendChild(iconDiv); } /** * Format surgery info with post-op days * @param {object} item - Patient item * @returns {string} - Formatted surgery info HTML */ function formatSurgeryInfo(item) { let ptInfo = ''; let surgeryDate = null; let ptData = null; if (item.phauThuatInfo) { ptData = item.phauThuatInfo; surgeryDate = ptData.date || ptData.ngayPhauThuat; } else if (item.checklistState && item.checklistState.phauThuatLog && item.checklistState.phauThuatLog.length > 0) { // Get latest surgery from checklist log ptData = item.checklistState.phauThuatLog[0]; surgeryDate = ptData.date; } if (ptData) { let dateTime = ''; if (ptData.date && ptData.time) { // New format from phauThuatHandlers dateTime = `${ptData.date} ${ptData.time}`; } else if (ptData.ngayPhauThuat && ptData.gioPhauThuat) { // Old format dateTime = `${ptData.ngayPhauThuat} ${ptData.gioPhauThuat}`; } else if (ptData.date) { dateTime = ptData.date; } else if (ptData.ngayPhauThuat) { dateTime = ptData.ngayPhauThuat; } const method = ptData.method || ptData.pppt || ''; // Calculate post-op days const surgeryInfo = getSurgeryDateInfo(surgeryDate); let postOpDisplay = ''; if (surgeryInfo && surgeryInfo.postOpDay !== null) { if (surgeryInfo.status === 'today') { postOpDisplay = ` <strong>(Hôm nay PT)</strong>`; } else { postOpDisplay = ` <strong>(HPN${surgeryInfo.postOpDay})</strong>`; } } else if (surgeryInfo && surgeryInfo.status === 'future') { const daysUntil = Math.abs(surgeryInfo.daysDiff); if (daysUntil === 1) { postOpDisplay = ` <strong>(Ngày mai phẫu thuật)</strong>`; } else if (daysUntil === 2) { postOpDisplay = ` <strong>(Ngày mốt PT)</strong>`; } else { postOpDisplay = ` <strong>(Còn ${daysUntil} ngày nữa PT)</strong>`; } } console.log('Surgery info found for patient:', item.mabn, 'PPPT:', method, 'DateTime:', dateTime, 'PostOp:', postOpDisplay); ptInfo = `<div class="dr-pt-info"> <div class="dr-value"><span class="dr-label">PPPT:</span> ${method}${postOpDisplay}</div> <div class="dr-value"><span class="dr-label">Ngày PT:</span> ${dateTime}</div> </div>`; } else { ptInfo = '<div class="dr-pt-info"></div>'; } return ptInfo; } /** * Update patient card surgery info * @param {object} patient - Patient data * @param {object} customChecklistState - Custom checklist state */ function updatePatientCardPhauThuat(patient, customChecklistState = null) { const cards = document.querySelectorAll('.dr-card'); for (let card of cards) { const cardTitle = card.querySelector('h2'); if (cardTitle && cardTitle.textContent.includes(patient.mabn)) { const checklistState = customChecklistState || window.checklistState; // Create patient object with updated checklist state for formatSurgeryInfo // Also ensure any existing phauThuatInfo is preserved/updated const patientWithState = { ...patient, checklistState: checklistState }; // If checklistState has phauThuatLog, update patient's phauThuatInfo with latest entry if (checklistState && checklistState.phauThuatLog && checklistState.phauThuatLog.length > 0) { const latestPT = checklistState.phauThuatLog[0]; // Latest is first patientWithState.phauThuatInfo = { date: latestPT.date, time: latestPT.time, method: latestPT.method, doctors: latestPT.doctors, // Keep backward compatibility ngayPhauThuat: latestPT.date, gioPhauThuat: latestPT.time, pppt: latestPT.method }; } // Use formatSurgeryInfo to get formatted surgery info with post-op days const formattedPtInfo = formatSurgeryInfo(patientWithState); // Find existing surgery info container and update const existingPTContainer = card.querySelector('.dr-pt-info'); if (existingPTContainer) { console.log('PT container found, updating with formatted info'); // Extract just the inner content from the formatted HTML const tempDiv = document.createElement('div'); tempDiv.innerHTML = formattedPtInfo; const innerContent = tempDiv.querySelector('.dr-pt-info'); if (innerContent) { existingPTContainer.innerHTML = innerContent.innerHTML; } console.log('Updated PT container with post-op days and latest surgery info'); } else { console.log('PT container not found for patient:', patient.mabn); } // Update surgery status icon with the latest info addSurgeryStatusIcon(card, patientWithState); break; } } } module.exports = { getSurgeryDateInfo, getSurgeryDateStatus, addSurgeryStatusIcon, formatSurgeryInfo, updatePatientCardPhauThuat }; },{}],42:[function(require,module,exports){ // tagUtils.js const BS_CAI_DAT = require('../BS_CAI_DAT_GIAO_DIEN'); // Helper function to create y lệnh tags function createYLenhTags(patient) { console.log('DEBUG createYLenhTags - patient:', patient.mabn, 'checklistState:', !!patient.checklistState); if (!patient.checklistState || !patient.checklistState.yLenhLog || !Array.isArray(patient.checklistState.yLenhLog)) { console.log('No yLenhLog found for patient:', patient.mabn); return ''; } // Filter for today's entries (INCLUDE all entries for dashboard cards) const today = new Date(); const todayStr = `${today.getDate().toString().padStart(2, '0')}/${(today.getMonth() + 1).toString().padStart(2, '0')}/${today.getFullYear()}`; const todayEntries = patient.checklistState.yLenhLog.filter(entry => { return entry.timestamp && entry.timestamp.startsWith(todayStr); }); // Exclude 'Đã đánh thuốc' from tags (both quick and manual entries) const filteredEntries = todayEntries.filter(entry => { const text = ((entry.action || entry.content || '') + '').trim().toLowerCase(); return text !== 'đã đánh thuốc'; }); console.log('Today entries (excluding meds-done) for patient', patient.mabn, ':', filteredEntries); if (filteredEntries.length === 0) { return ''; } // Take only first 3 entries (most recent) const displayEntries = filteredEntries.slice(0, 3); const tagsHtml = displayEntries.map(entry => { // Determine tag color based on content let color = '#4caf50'; // default green const content = (entry.content || '').toLowerCase(); let isDischarge = false; if (content.includes('xuất viện')) { color = '#4caf50'; isDischarge = true; } else if (content.includes('rút odl')) color = '#ff9800'; else if (content.includes('sonde')) color = '#9c27b0'; else if (content.includes('thay băng')) color = '#2196f3'; // Quick-action state mapping let stateClass = ''; let stateIcon = '📋'; if (entry.q === true) { const st = entry.status || 'active'; if (st === 'active') { stateClass = ' state-active'; stateIcon = '⏳'; } if (st === 'done') { stateClass = ' state-done'; stateIcon = '✔'; } } const dischargeClass = isDischarge ? ' discharge' : ''; const classes = `ylenh-tag${dischargeClass}${stateClass}`; // Append discharge time if available and is Xuất viện const timeText = (isDischarge && entry.dischargeTime) ? ` (${entry.dischargeTime})` : ''; return `<span class="${classes}" style="background-color: rgba(${hexToRgb(color)}, 0.1); color: ${color}; border-color: rgba(${hexToRgb(color)}, 0.3);"> <span class="icon">${stateIcon}</span> <span style="overflow-wrap:anywhere; word-break:break-word;">${entry.content}${timeText}</span> </span>`; }).join(''); console.log('Generated tags HTML for patient', patient.mabn, ':', tagsHtml); return `<div class="ylenh-tags">${tagsHtml}</div>`; } // Helper function to convert hex to RGB function hexToRgb(hex) { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}` : '76, 175, 80'; // fallback green } // Compute if today has a quick action 'Đã đánh thuốc' marked done function hasMedsDoneToday(patient) { try { if (!patient || !patient.checklistState || !Array.isArray(patient.checklistState.yLenhLog)) return false; const today = new Date(); const todayStr = `${today.getDate().toString().padStart(2, '0')}/${(today.getMonth() + 1).toString().padStart(2, '0')}/${today.getFullYear()}`; return patient.checklistState.yLenhLog.some(entry => { if (!entry || !entry.timestamp || !entry.content) return false; if (!entry.timestamp.startsWith(todayStr)) return false; const isQuick = entry.q === true && (entry.action ? entry.action === 'Đã đánh thuốc' : entry.content === 'Đã đánh thuốc'); const isManual = !entry.q && entry.content === 'Đã đánh thuốc'; if (isQuick) return entry.status === 'done'; return isManual; // if someone typed it manually, count it }); } catch (_) { return false; } } // Add or remove the meds-done badge on a specific card element function updateMedsDoneBadge(card, patient) { try { if (!card) return; const shouldShow = hasMedsDoneToday(patient); // List view: manage inline badge inside actions, do not use absolute badge if (card.classList.contains('dr-list-row')) { let corner = card.querySelector('.dr-badge-meds-row-corner'); if (shouldShow) { if (!corner) { corner = document.createElement('span'); corner.className = 'dr-badge-meds-row-corner'; corner.textContent = 'Đã đánh thuốc'; card.appendChild(corner); } } else if (corner) { corner.remove(); } return; } // Card view: original absolute badge behavior const existed = card.querySelector('.dr-badge-meds-done'); if (shouldShow) { if (!existed) { const badge = document.createElement('div'); badge.className = 'dr-badge-meds-done'; badge.textContent = 'Đã đánh thuốc'; card.appendChild(badge); } card.classList.add('meds-done'); } else if (existed) { existed.remove(); card.classList.remove('meds-done'); } } catch (_) { /* noop */ } } // Helper function to check for discharge tags and add xuatvienanimation class function checkAndAddCelebrationClass(card, patient) { if (!patient || !patient.checklistState || !patient.checklistState.yLenhLog) { card.classList.remove('xuatvienanimation'); console.log('No checklistState or yLenhLog for patient:', patient?.mabn); return; } // Check if today's entries include "Xuất viện" (including quick actions) const today = new Date(); const todayStr = `${today.getDate().toString().padStart(2, '0')}/${(today.getMonth() + 1).toString().padStart(2, '0')}/${today.getFullYear()}`; console.log('DEBUG checkAndAddCelebrationClass - Today:', todayStr); console.log('DEBUG checkAndAddCelebrationClass - yLenhLog entries:', patient.checklistState.yLenhLog); // Check ALL entries (including quick actions) for "xuất viện" const dischargeEntries = patient.checklistState.yLenhLog.filter(entry => { const hasDischarge = entry.content && entry.content.toLowerCase().includes('xuất viện'); const isToday = entry.timestamp && entry.timestamp.startsWith(todayStr); // If quick action, count both active and done for celebration if (entry.q === true && entry.action === 'Xuất viện' && isToday) { return entry.status === 'active' || entry.status === 'done'; } console.log('DEBUG entry:', entry.content, 'timestamp:', entry.timestamp, 'hasDischarge:', hasDischarge, 'isToday:', isToday); // Check for today's discharge entries (including quick actions) return hasDischarge && isToday; }); console.log('DEBUG discharge entries found:', dischargeEntries); if (dischargeEntries.length > 0) { card.classList.add('xuatvienanimation'); console.log('🎉 Added xuatvienanimation class to card for patient:', patient.mabn); } else { card.classList.remove('xuatvienanimation'); console.log('❌ No discharge entries found for patient:', patient.mabn); } } // Global function to update patient card tags function updatePatientCardTags(patientMabn) { console.log('updatePatientCardTags called for patient:', patientMabn); if (!window.dr_data) { console.log('No dr_data found'); return; } // Find patient in data const patient = window.dr_data.find(p => p.mabn === patientMabn); if (!patient) { console.log('Patient not found in dr_data:', patientMabn); return; } // Prefer data-mabn matching on both card and list rows console.log('Looking for patient element (card or row) with mabn:', patientMabn); let targetCard = document.querySelector(`.dr-card[data-mabn="${patientMabn}"]`) || document.querySelector(`.dr-list-row[data-mabn="${patientMabn}"]`); if (!targetCard) { // Fallback: scan text in .dr-card only (legacy) const allCards = document.querySelectorAll('.dr-card'); allCards.forEach((card) => { const cardText = card.textContent || card.innerText || ''; if (cardText.includes(patientMabn)) targetCard = card; }); } if (!targetCard) { console.log('Patient card not found in DOM for:', patientMabn); console.log('Available card text snippets:'); allCards.forEach((card, index) => { const cardText = card.textContent || card.innerText || ''; console.log(` Card ${index}:`, cardText.substring(0, 50)); }); return; } console.log('Found patient card for:', patientMabn); // Remove existing tags from anywhere in the element const existingTags = targetCard.querySelector('.ylenh-tags'); if (existingTags) { existingTags.remove(); console.log('Removed existing tags'); } // Create new tags const tagsHtml = createYLenhTags(patient); if (tagsHtml) { // Insert tags appropriately let placed = false; const actionButtons = targetCard.querySelector('.dr-action-buttons'); if (actionButtons) { actionButtons.insertAdjacentHTML('beforebegin', tagsHtml); placed = true; } if (!placed) { const left = targetCard.querySelector(':scope > div'); if (left) left.insertAdjacentHTML('beforeend', tagsHtml); else targetCard.insertAdjacentHTML('beforeend', tagsHtml); } // Update dataset flags for filters (today only) try { const today = new Date(); const todayStr = `${today.getDate().toString().padStart(2, '0')}/${(today.getMonth() + 1).toString().padStart(2, '0')}/${today.getFullYear()}`; const log = patient && patient.checklistState && Array.isArray(patient.checklistState.yLenhLog) ? patient.checklistState.yLenhLog : []; let hasXV = false, hasCLS = false; for (const e of log) { if (!e.timestamp || !e.content) continue; if (!e.timestamp.startsWith(todayStr)) continue; const c = e.content.toLowerCase(); if (c.includes('xuất viện')) { if (e.q === true && e.action === 'Xuất viện') { if (e.status === 'active' || e.status === 'done') hasXV = true; } else { hasXV = true; } } if (c.includes('cận lâm sàng')) hasCLS = true; } targetCard.dataset.hasxv = hasXV ? '1' : '0'; targetCard.dataset.hascls = hasCLS ? '1' : '0'; } catch (_) {} } // Update discharge celebration class and meds-done badge regardless of tags presence checkAndAddCelebrationClass(targetCard, patient); updateMedsDoneBadge(targetCard, patient); } // Make updatePatientCardTags globally available if (typeof window !== 'undefined') { window.updatePatientCardTags = updatePatientCardTags; } // Helper function to check if patient has discharge tag function hasDischargeTag(patient) { if (!patient || !patient.checklistState || !patient.checklistState.yLenhLog) { return false; } return patient.checklistState.yLenhLog.some(entry => { return entry.content && entry.content.toLowerCase().includes('xuất viện'); }); } module.exports = { createYLenhTags, updatePatientCardTags, hexToRgb, hasDischargeTag, hasMedsDoneToday, updateMedsDoneBadge }; },{"../BS_CAI_DAT_GIAO_DIEN":1}],43:[function(require,module,exports){ // uiUtils.js - UI utility functions /** * Show toast notification * @param {string} message - Message to display * @param {object} options - Options for toast * @param {string} options.background - Background color * @param {string} options.color - Text color * @param {number} options.duration - Duration in milliseconds */ function showToast(message, options = {}) { const toast = document.createElement('div'); toast.innerText = message; toast.style.cssText = ` position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); background: ${options.background || '#4caf50'}; color: ${options.color || '#fff'}; padding: 12px 28px; border-radius: 8px; font-size: 1.1em; z-index: 1000002; box-shadow: 0 2px 12px rgba(76, 175, 80, 0.3); transition: opacity 0.3s; font-weight: 500; `; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 400); }, options.duration || 2000); } /** * Copy text to clipboard * @param {string} text - Text to copy * @returns {Promise<boolean>} - Success status */ async function copyToClipboard(text) { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; } else { // Fallback for older browsers or non-secure contexts const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const success = document.execCommand('copy'); textArea.remove(); return success; } } catch (err) { console.error('Failed to copy: ', err); return false; } } module.exports = { showToast, copyToClipboard }; },{}]},{},[3]);