2026-10-26 00:10:07 -07:00
// DH-HIS Quản Lý Giao Ban Web Client
const API_BASE = window . location . pathname . startsWith ( '/web-giao-ban-go' ) ? '/web-giao-ban-go' : ( window . location . pathname . startsWith ( '/web-giao-ban' ) ? '/web-giao-ban' : '' );
let chartNgoaiTruInstance = null ;
let chartNoiTruInstance = null ;
let currentChartTypeNgoaiTru = 'khoa' ;
2026-10-27 02:04:23 -07:00
let currentChartTypeNoiTru = 'noitru-col' ;
let isBienBanLocked = false ;
2026-10-26 00:10:07 -07:00
let rawNgoaiTruData = null ;
let rawNoiTruData = null ;
let rawBang3Data = [];
let rawBang4Data = [];
let rawIssuesData = [];
let currentIssueFile = null ;
let editingIssueId = null ;
document . addEventListener ( 'DOMContentLoaded' , async () => {
initPresets ();
initTabs ();
initEvents ();
initIssueFeatures ();
2026-10-27 02:04:23 -07:00
initBienBan ();
await checkLicenseStatus ();
2026-10-26 00:10:07 -07:00
await loadDatabases ();
loadData ();
loadIssues ( false );
});
// Format Number with Vietnamese Thousand Separator
function fmt ( num ) {
if ( num === null || num === undefined || isNaN ( num )) return '0' ;
return Number ( num ). toLocaleString ( 'vi-VN' );
}
// Show Toast
function showToast ( msg ) {
const toast = document . getElementById ( 'toast' );
const toastMsg = document . getElementById ( 'toastMsg' );
if ( ! toast || ! toastMsg ) return ;
toastMsg . innerText = msg ;
toast . classList . remove ( 'translate-y-20' , 'opacity-0' );
toast . classList . add ( 'translate-y-0' , 'opacity-100' );
setTimeout (() => {
toast . classList . remove ( 'translate-y-0' , 'opacity-100' );
toast . classList . add ( 'translate-y-20' , 'opacity-0' );
}, 2500 );
}
// Load dynamic databases list
async function loadDatabases () {
const selectDb = document . getElementById ( 'selectDb' );
if ( ! selectDb ) return ;
try {
const res = await fetch ( ` ${ API_BASE } /api/databases` );
const json = await res . json ();
if ( json . success && Array . isArray ( json . data ) && json . data . length > 0 ) {
const savedDb = localStorage . getItem ( 'dh_giaoban_db' ) || selectDb . value || 'an_omon_full_04052026' ;
selectDb . innerHTML = '' ;
json . data . forEach ( db => {
const opt = document . createElement ( 'option' );
opt . value = db . id ;
opt . textContent = db . name ;
if ( db . id === savedDb ) opt . selected = true ;
selectDb . appendChild ( opt );
});
updateDbBadge ();
}
} catch ( err ) {
console . warn ( 'Không tải được danh sách CSDL từ server:' , err );
}
}
// Update Database Badge on Header
function updateDbBadge () {
const selectDb = document . getElementById ( 'selectDb' );
const badge = document . getElementById ( 'currentDbBadge' );
if ( badge && selectDb && selectDb . selectedOptions [ 0 ]) {
const text = selectDb . selectedOptions [ 0 ]. textContent . split ( '(' )[ 0 ]. trim ();
badge . innerHTML = `<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse inline-block"></span> ${ text } ` ;
}
}
// Preset Handler
function initPresets () {
const quickPreset = document . getElementById ( 'quickPreset' );
const dateFrom = document . getElementById ( 'dateFrom' );
const dateTo = document . getElementById ( 'dateTo' );
2026-10-27 02:04:23 -07:00
const lockStatusFrom = document . getElementById ( 'lockStatusFrom' );
const lockStatusTo = document . getElementById ( 'lockStatusTo' );
const quickComparePreset = document . getElementById ( 'quickComparePreset' );
const compareFrom = document . getElementById ( 'compareFrom' );
const compareTo = document . getElementById ( 'compareTo' );
const chkHideZeroRows = document . getElementById ( 'chkHideZeroRows' );
function updateLockState () {
const isCustom = quickPreset . value === 'custom' ;
dateFrom . readOnly = ! isCustom ;
dateTo . readOnly = ! isCustom ;
if ( isCustom ) {
dateFrom . classList . remove ( 'bg-slate-100' , 'cursor-not-allowed' , 'text-slate-500' );
dateFrom . classList . add ( 'bg-white' , 'text-slate-800' );
dateTo . classList . remove ( 'bg-slate-100' , 'cursor-not-allowed' , 'text-slate-500' );
dateTo . classList . add ( 'bg-white' , 'text-slate-800' );
if ( lockStatusFrom ) lockStatusFrom . innerHTML = '<i class="fa-solid fa-lock-open text-emerald-500 text-[10px]"></i> Mở khóa' ;
if ( lockStatusTo ) lockStatusTo . innerHTML = '<i class="fa-solid fa-lock-open text-emerald-500 text-[10px]"></i> Mở khóa' ;
} else {
dateFrom . classList . add ( 'bg-slate-100' , 'cursor-not-allowed' , 'text-slate-500' );
dateFrom . classList . remove ( 'bg-white' , 'text-slate-800' );
dateTo . classList . add ( 'bg-slate-100' , 'cursor-not-allowed' , 'text-slate-500' );
dateTo . classList . remove ( 'bg-white' , 'text-slate-800' );
if ( lockStatusFrom ) lockStatusFrom . innerHTML = '<i class="fa-solid fa-lock text-slate-400 text-[10px]"></i> Cố định' ;
if ( lockStatusTo ) lockStatusTo . innerHTML = '<i class="fa-solid fa-lock text-slate-400 text-[10px]"></i> Cố định' ;
}
}
2026-10-26 00:10:07 -07:00
quickPreset . addEventListener ( 'change' , () => {
const val = quickPreset . value ;
2026-10-27 02:04:23 -07:00
updateLockState ();
2026-10-26 00:10:07 -07:00
let f = '' , t = '' ;
switch ( val ) {
case 'month-03-2026' :
f = '2026-03-01' ;
t = '2026-03-31' ;
break ;
case 'month-04-2026' :
f = '2026-04-01' ;
t = '2026-04-30' ;
break ;
case 'month-01-2026' :
f = '2026-01-01' ;
t = '2026-01-31' ;
break ;
case 'month-02-2026' :
f = '2026-02-01' ;
t = '2026-02-28' ;
break ;
case 'today' :
f = '2026-03-31' ;
t = '2026-03-31' ;
break ;
case 'yesterday' :
f = '2026-03-30' ;
t = '2026-03-30' ;
break ;
case 'this-month' :
f = '2026-03-01' ;
t = '2026-03-31' ;
break ;
case 'quarter-1' :
f = '2026-01-01' ;
t = '2026-03-31' ;
break ;
case 'year-2026' :
f = '2026-01-01' ;
t = '2026-12-31' ;
break ;
2026-10-27 02:04:23 -07:00
case 'custom' :
return ;
2026-10-26 00:10:07 -07:00
default :
return ;
}
dateFrom . value = f ;
dateTo . value = t ;
2026-10-27 02:04:23 -07:00
// Auto-update compare preset if relative
if ( quickComparePreset && quickComparePreset . value !== 'custom' ) {
updateComparePresetDates ();
}
2026-10-26 00:10:07 -07:00
loadData ();
});
2026-10-27 02:04:23 -07:00
function updateComparePresetDates () {
if ( ! quickComparePreset || ! compareFrom || ! compareTo ) return ;
const val = quickComparePreset . value ;
const isCustom = val === 'custom' ;
compareFrom . readOnly = ! isCustom ;
compareTo . readOnly = ! isCustom ;
if ( isCustom ) {
compareFrom . classList . remove ( 'bg-slate-100' , 'cursor-not-allowed' , 'text-slate-500' );
compareFrom . classList . add ( 'bg-white' , 'text-slate-800' );
compareTo . classList . remove ( 'bg-slate-100' , 'cursor-not-allowed' , 'text-slate-500' );
compareTo . classList . add ( 'bg-white' , 'text-slate-800' );
return ;
}
compareFrom . classList . add ( 'bg-slate-100' , 'cursor-not-allowed' , 'text-slate-500' );
compareFrom . classList . remove ( 'bg-white' , 'text-slate-800' );
compareTo . classList . add ( 'bg-slate-100' , 'cursor-not-allowed' , 'text-slate-500' );
compareTo . classList . remove ( 'bg-white' , 'text-slate-800' );
if ( val === 'month-01-2026' ) {
compareFrom . value = '2026-01-01' ;
compareTo . value = '2026-01-31' ;
} else if ( val === 'month-02-2026' ) {
compareFrom . value = '2026-02-01' ;
compareTo . value = '2026-02-28' ;
} else if ( val === 'prev-month' ) {
try {
const d1 = new Date ( dateFrom . value );
d1 . setMonth ( d1 . getMonth () - 1 );
const d2 = new Date ( dateTo . value );
d2 . setMonth ( d2 . getMonth () - 1 );
compareFrom . value = d1 . toISOString (). slice ( 0 , 10 );
compareTo . value = d2 . toISOString (). slice ( 0 , 10 );
} catch ( e ) {}
} else if ( val === 'prev-year' ) {
try {
const d1 = new Date ( dateFrom . value );
d1 . setFullYear ( d1 . getFullYear () - 1 );
const d2 = new Date ( dateTo . value );
d2 . setFullYear ( d2 . getFullYear () - 1 );
compareFrom . value = d1 . toISOString (). slice ( 0 , 10 );
compareTo . value = d2 . toISOString (). slice ( 0 , 10 );
} catch ( e ) {}
} else if ( val === 'prev-period' ) {
try {
const d1 = new Date ( dateFrom . value );
const d2 = new Date ( dateTo . value );
const diff = d2 . getTime () - d1 . getTime ();
const prevEnd = new Date ( d1 . getTime () - 24 * 3600 * 1000 );
const prevStart = new Date ( prevEnd . getTime () - diff );
compareFrom . value = prevStart . toISOString (). slice ( 0 , 10 );
compareTo . value = prevEnd . toISOString (). slice ( 0 , 10 );
} catch ( e ) {}
}
}
if ( quickComparePreset ) {
quickComparePreset . addEventListener ( 'change' , () => {
updateComparePresetDates ();
loadData ();
});
}
if ( chkHideZeroRows ) {
chkHideZeroRows . addEventListener ( 'change' , () => {
renderNgoaiTru ();
renderNoiTru ();
});
}
updateLockState ();
updateComparePresetDates ();
2026-10-26 00:10:07 -07:00
}
// Tab Switching
function initTabs () {
const tabBtns = document . querySelectorAll ( '.tab-btn' );
const tabContents = document . querySelectorAll ( '.tab-content' );
tabBtns . forEach ( btn => {
btn . addEventListener ( 'click' , () => {
tabBtns . forEach ( b => {
b . classList . remove ( 'active' , 'border-blue-600' , 'text-blue-600' );
b . classList . add ( 'border-transparent' , 'text-slate-500' );
});
btn . classList . add ( 'active' , 'border-blue-600' , 'text-blue-600' );
btn . classList . remove ( 'border-transparent' , 'text-slate-500' );
const target = btn . getAttribute ( 'data-tab' );
tabContents . forEach ( c => {
c . classList . toggle ( 'hidden' , c . id !== target );
});
// Resize charts or load issues
if ( target === 'tab-ngoaitru' && chartNgoaiTruInstance ) {
chartNgoaiTruInstance . resize ();
} else if ( target === 'tab-noitru' && chartNoiTruInstance ) {
chartNoiTruInstance . resize ();
} else if ( target === 'tab-issues' ) {
loadIssues ( true );
}
});
});
}
// Events
function initEvents () {
const enableCompare = document . getElementById ( 'enableCompare' );
const compareContainer = document . getElementById ( 'compareContainer' );
const btnFilter = document . getElementById ( 'btnFilter' );
const selectDb = document . getElementById ( 'selectDb' );
const btnPrint = document . getElementById ( 'btnPrint' );
const btnExport = document . getElementById ( 'btnExport' );
enableCompare . addEventListener ( 'change' , () => {
compareContainer . classList . toggle ( 'hidden' , ! enableCompare . checked );
document . querySelectorAll ( '.th-compare' ). forEach ( el => {
el . classList . toggle ( 'hidden' , ! enableCompare . checked );
});
loadData ();
});
btnFilter . addEventListener ( 'click' , () => loadData ());
selectDb . addEventListener ( 'change' , () => {
updateDbBadge ();
loadData ();
});
btnPrint . addEventListener ( 'click' , () => window . print ());
btnExport . addEventListener ( 'click' , () => exportExcel ());
// Chart Type toggle for Outpatient
document . querySelectorAll ( '.chart-type-btn' ). forEach ( btn => {
btn . addEventListener ( 'click' , () => {
document . querySelectorAll ( '.chart-type-btn' ). forEach ( b => {
b . classList . remove ( 'active' , 'bg-blue-100' , 'text-blue-700' , 'font-semibold' );
b . classList . add ( 'bg-slate-100' , 'text-slate-600' , 'font-medium' );
});
btn . classList . add ( 'active' , 'bg-blue-100' , 'text-blue-700' , 'font-semibold' );
btn . classList . remove ( 'bg-slate-100' , 'text-slate-600' , 'font-medium' );
currentChartTypeNgoaiTru = btn . getAttribute ( 'data-chart' );
renderChartNgoaiTru ();
});
});
2026-10-27 02:04:23 -07:00
// Chart Type toggle for Inpatient
document . querySelectorAll ( '.chart-nt-btn' ). forEach ( btn => {
btn . addEventListener ( 'click' , () => {
document . querySelectorAll ( '.chart-nt-btn' ). forEach ( b => {
b . classList . remove ( 'active' , 'bg-blue-100' , 'text-blue-700' , 'font-semibold' );
b . classList . add ( 'bg-slate-100' , 'text-slate-600' , 'font-medium' );
});
btn . classList . add ( 'active' , 'bg-blue-100' , 'text-blue-700' , 'font-semibold' );
btn . classList . remove ( 'bg-slate-100' , 'text-slate-600' , 'font-medium' );
currentChartTypeNoiTru = btn . getAttribute ( 'data-ntchart' );
renderChartNoiTru ();
});
});
// License Modal close button
const btnCloseLicense = document . getElementById ( 'btnCloseLicenseModal' );
if ( btnCloseLicense ) {
btnCloseLicense . addEventListener ( 'click' , () => {
document . getElementById ( 'modalLicenseBlock' ) ? . classList . add ( 'hidden' );
});
}
2026-10-26 00:10:07 -07:00
// Filters for Table 3 & 4
document . getElementById ( 'filterKhoaCv' ). addEventListener ( 'change' , ( e ) => {
renderBang3 ( e . target . value );
});
document . getElementById ( 'filterKhoaTd' ). addEventListener ( 'change' , ( e ) => {
renderBang4 ( e . target . value );
});
}
// Get Request Params
function getParams () {
const database = document . getElementById ( 'selectDb' ). value ;
const from = document . getElementById ( 'dateFrom' ). value + ' 00:00:00' ;
const to = document . getElementById ( 'dateTo' ). value + ' 23:59:59' ;
const isCompare = document . getElementById ( 'enableCompare' ). checked ;
let urlParams = `database= ${ database } &from= ${ encodeURIComponent ( from ) } &to= ${ encodeURIComponent ( to ) } ` ;
if ( isCompare ) {
const compareFrom = document . getElementById ( 'compareFrom' ). value + ' 00:00:00' ;
const compareTo = document . getElementById ( 'compareTo' ). value + ' 23:59:59' ;
urlParams += `&compareFrom= ${ encodeURIComponent ( compareFrom ) } &compareTo= ${ encodeURIComponent ( compareTo ) } ` ;
}
return { database , from , to , isCompare , urlParams };
}
// Set Loading State
function setLoading ( isLoading ) {
const btnFilter = document . getElementById ( 'btnFilter' );
const selectDb = document . getElementById ( 'selectDb' );
const quickPreset = document . getElementById ( 'quickPreset' );
if ( isLoading ) {
if ( btnFilter ) {
btnFilter . disabled = true ;
btnFilter . innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> <span>Đang truy vấn...</span>' ;
btnFilter . classList . add ( 'opacity-75' , 'cursor-not-allowed' );
}
if ( selectDb ) selectDb . disabled = true ;
if ( quickPreset ) quickPreset . disabled = true ;
// Show skeletons/loading on tables
const bodyKhoa = document . getElementById ( 'bodyKhoaKcb' );
if ( bodyKhoa ) bodyKhoa . innerHTML = '<tr><td colspan="8" class="text-center py-8 text-blue-600 font-medium"><i class="fa-solid fa-spinner fa-spin text-xl mr-2"></i> Đang tải số liệu khám chữa bệnh...</td></tr>' ;
const bodyCls = document . getElementById ( 'bodyCls' );
if ( bodyCls ) bodyCls . innerHTML = '<tr><td colspan="4" class="text-center py-6 text-blue-600 font-medium"><i class="fa-solid fa-spinner fa-spin mr-2"></i> Đang tải Cận lâm sàng...</td></tr>' ;
const bodyTop10 = document . getElementById ( 'bodyTop10' );
if ( bodyTop10 ) bodyTop10 . innerHTML = '<tr><td colspan="3" class="text-center py-6 text-blue-600 font-medium"><i class="fa-solid fa-spinner fa-spin mr-2"></i> Đang tải Top 10 bệnh...</td></tr>' ;
const bodyNoiTru = document . getElementById ( 'bodyNoiTru' );
if ( bodyNoiTru ) bodyNoiTru . innerHTML = '<tr><td colspan="12" class="text-center py-8 text-blue-600 font-medium"><i class="fa-solid fa-spinner fa-spin text-xl mr-2"></i> Đang tải số liệu nội trú...</td></tr>' ;
const bodyBang3 = document . getElementById ( 'bodyBang3' );
if ( bodyBang3 ) bodyBang3 . innerHTML = '<tr><td colspan="9" class="text-center py-8 text-blue-600 font-medium"><i class="fa-solid fa-spinner fa-spin text-xl mr-2"></i> Đang tải danh sách chuyển viện...</td></tr>' ;
const bodyBang4 = document . getElementById ( 'bodyBang4' );
if ( bodyBang4 ) bodyBang4 . innerHTML = '<tr><td colspan="9" class="text-center py-8 text-blue-600 font-medium"><i class="fa-solid fa-spinner fa-spin text-xl mr-2"></i> Đang tải danh sách theo dõi & CS Cấp I...</td></tr>' ;
} else {
if ( btnFilter ) {
btnFilter . disabled = false ;
btnFilter . innerHTML = '<i class="fa-solid fa-magnifying-glass"></i> <span>Xem Báo Cáo</span>' ;
btnFilter . classList . remove ( 'opacity-75' , 'cursor-not-allowed' );
}
if ( selectDb ) selectDb . disabled = false ;
if ( quickPreset ) quickPreset . disabled = false ;
}
}
// Master Load Data
async function loadData () {
const { urlParams , database , from , to } = getParams ();
localStorage . setItem ( 'dh_giaoban_db' , database );
updateDbBadge ();
setLoading ( true );
try {
const [ resNt , resNoiTru , resB3 , resB4 ] = await Promise . allSettled ([
fetch ( ` ${ API_BASE } /api/giao-ban-ngoai-tru? ${ urlParams } ` ). then ( r => r . json ()),
fetch ( ` ${ API_BASE } /api/giao-ban-noi-tru? ${ urlParams } ` ). then ( r => r . json ()),
fetch ( ` ${ API_BASE } /api/danh-sach-chuyen-vien?database= ${ database } &from= ${ encodeURIComponent ( from ) } &to= ${ encodeURIComponent ( to ) } ` ). then ( r => r . json ()),
fetch ( ` ${ API_BASE } /api/danh-sach-theo-doi?database= ${ database } &from= ${ encodeURIComponent ( from ) } &to= ${ encodeURIComponent ( to ) } ` ). then ( r => r . json ()),
]);
let hasSuccess = false ;
let errors = [];
// 1. Ngoại trú
if ( resNt . status === 'fulfilled' && resNt . value && resNt . value . success ) {
rawNgoaiTruData = resNt . value . data ;
renderNgoaiTru ();
hasSuccess = true ;
} else {
const err = resNt . status === 'rejected' ? resNt . reason ? . message : resNt . value ? . message ;
errors . push ( `Ngoại trú: ${ err || 'Lỗi kết nối' } ` );
const body = document . getElementById ( 'bodyKhoaKcb' );
if ( body ) body . innerHTML = `<tr><td colspan="8" class="text-center py-6 text-rose-500 font-medium"><i class="fa-solid fa-triangle-exclamation mr-1"></i> Không tải được số liệu ngoại trú: ${ err || 'Lỗi' } </td></tr>` ;
}
// 2. Nội trú
if ( resNoiTru . status === 'fulfilled' && resNoiTru . value && resNoiTru . value . success ) {
rawNoiTruData = resNoiTru . value . data ;
renderNoiTru ();
hasSuccess = true ;
} else {
const err = resNoiTru . status === 'rejected' ? resNoiTru . reason ? . message : resNoiTru . value ? . message ;
errors . push ( `Nội trú: ${ err || 'Lỗi kết nối' } ` );
const body = document . getElementById ( 'bodyNoiTru' );
if ( body ) body . innerHTML = `<tr><td colspan="12" class="text-center py-6 text-rose-500 font-medium"><i class="fa-solid fa-triangle-exclamation mr-1"></i> Không tải được số liệu nội trú: ${ err || 'Lỗi' } </td></tr>` ;
}
// 3. Bảng 3: Chuyển viện
if ( resB3 . status === 'fulfilled' && resB3 . value && resB3 . value . success ) {
rawBang3Data = resB3 . value . data ;
populateKhoaDropdown ( 'filterKhoaCv' , rawBang3Data );
renderBang3 ( 'ALL' );
hasSuccess = true ;
} else {
const err = resB3 . status === 'rejected' ? resB3 . reason ? . message : resB3 . value ? . message ;
errors . push ( `Chuyển viện: ${ err || 'Lỗi kết nối' } ` );
const body = document . getElementById ( 'bodyBang3' );
if ( body ) body . innerHTML = `<tr><td colspan="9" class="text-center py-6 text-rose-500 font-medium"><i class="fa-solid fa-triangle-exclamation mr-1"></i> Không tải được danh sách chuyển viện</td></tr>` ;
}
// 4. Bảng 4: Theo dõi
if ( resB4 . status === 'fulfilled' && resB4 . value && resB4 . value . success ) {
rawBang4Data = resB4 . value . data ;
populateKhoaDropdown ( 'filterKhoaTd' , rawBang4Data );
renderBang4 ( 'ALL' );
hasSuccess = true ;
} else {
const err = resB4 . status === 'rejected' ? resB4 . reason ? . message : resB4 . value ? . message ;
errors . push ( `Theo dõi: ${ err || 'Lỗi kết nối' } ` );
const body = document . getElementById ( 'bodyBang4' );
if ( body ) body . innerHTML = `<tr><td colspan="9" class="text-center py-6 text-rose-500 font-medium"><i class="fa-solid fa-triangle-exclamation mr-1"></i> Không tải được danh sách theo dõi</td></tr>` ;
}
2026-10-27 02:04:23 -07:00
// Kiểm tra chặn bản quyền từ backend
const licBlocked = [ resNt , resNoiTru , resB3 , resB4 ]. find ( r => r . status === 'fulfilled' && r . value && r . value . license_valid === false );
if ( licBlocked ) {
showLicenseBlockModal ( licBlocked . value . message );
}
2026-10-26 00:10:07 -07:00
if ( hasSuccess ) {
const selName = document . getElementById ( 'selectDb' ). selectedOptions [ 0 ] ? . textContent . split ( '(' )[ 0 ]. trim () || database ;
showToast ( `Đã nạp báo cáo CSDL: ${ selName } ` );
}
if ( errors . length > 0 ) {
console . warn ( 'Errors loading some report sections:' , errors );
showToast ( `Cảnh báo: ${ errors . join ( '; ' ) } ` );
}
} catch ( err ) {
console . error ( 'Fatal load error:' , err );
showToast ( 'Lỗi nghiêm trọng khi tải dữ liệu: ' + err . message );
} finally {
setLoading ( false );
}
}
2026-10-27 02:04:23 -07:00
// Hiển thị Modal thông báo yêu cầu kích hoạt bản quyền
function showLicenseBlockModal ( msg ) {
const modal = document . getElementById ( 'modalLicenseBlock' );
const msgEl = document . getElementById ( 'modalLicenseBlockMsg' );
if ( msgEl && msg ) {
msgEl . innerHTML = `<i class="fa-solid fa-triangle-exclamation mr-1.5"></i> ${ msg } ` ;
}
if ( modal ) {
modal . classList . remove ( 'hidden' );
}
}
// Kiểm tra trạng thái bản quyền từ API
async function checkLicenseStatus () {
const badge = document . getElementById ( 'headerLicenseBadge' );
const text = document . getElementById ( 'headerLicenseText' );
const banner = document . getElementById ( 'licenseBanner' );
const bannerText = document . getElementById ( 'licenseBannerText' );
try {
const res = await fetch ( ` ${ API_BASE } /api/license/status` );
const json = await res . json ();
if ( json . success && json . data ) {
const d = json . data ;
if ( d . is_valid ) {
if ( badge ) {
badge . classList . remove ( 'hidden' );
badge . className = 'text-[11px] px-2.5 py-0.5 rounded-full font-bold bg-emerald-500/20 text-emerald-200 border border-emerald-400/30 flex items-center gap-1.5' ;
if ( text ) text . innerHTML = `<i class="fa-solid fa-circle-check text-emerald-300"></i> ${ d . hospital || d . title || 'Bản quyền hợp lệ' } (HSD: ${ d . expires || 'Vô thời hạn' } )` ;
}
if ( banner ) banner . classList . add ( 'hidden' );
} else {
if ( badge ) {
badge . classList . remove ( 'hidden' );
badge . className = 'text-[11px] px-2.5 py-0.5 rounded-full font-bold bg-rose-500/30 text-rose-200 border border-rose-400/30 flex items-center gap-1.5' ;
if ( text ) text . innerHTML = `<i class="fa-solid fa-triangle-exclamation text-rose-300"></i> Chưa có bản quyền` ;
}
if ( banner ) {
banner . classList . remove ( 'hidden' );
if ( bannerText ) bannerText . innerText = d . reason || 'Hệ thống chưa cập nhật key bản quyền tính năng Web Giao Ban' ;
}
}
}
} catch ( err ) {
console . warn ( 'Không kiểm tra được bản quyền:' , err );
}
}
2026-10-26 00:10:07 -07:00
// Populate Khoa Dropdown
function populateKhoaDropdown ( selectId , dataList ) {
const sel = document . getElementById ( selectId );
const currentVal = sel . value ;
const depts = Array . from ( new Set ( dataList . map ( item => item . tendv ). filter ( Boolean )));
sel . innerHTML = '<option value="ALL">Tất cả khoa</option>' ;
depts . forEach ( d => {
sel . innerHTML += `<option value=" ${ d } "> ${ d } </option>` ;
});
if ( depts . includes ( currentVal )) {
sel . value = currentVal ;
}
}
// ========================================================
// RENDER NGOẠI TRÚ
// ========================================================
function renderNgoaiTru () {
if ( ! rawNgoaiTruData ) return ;
const cur = rawNgoaiTruData . current ;
const comp = rawNgoaiTruData . compare ;
const isCompare = !! comp ;
// KPIs
let totalKham = 0 , totalBhyt = 0 , totalThuPhi = 0 , totalChuyenVien = 0 ;
cur . khoa . forEach ( k => {
totalKham += parseInt ( k . tong_kham || 0 , 10 );
totalBhyt += parseInt ( k . bhyt || 0 , 10 );
totalThuPhi += parseInt ( k . thuphi || 0 , 10 );
totalChuyenVien += parseInt ( k . chuyenvien || 0 , 10 );
});
document . getElementById ( 'kpiTongKham' ). innerText = fmt ( totalKham );
document . getElementById ( 'kpiBhyt' ). innerText = fmt ( totalBhyt );
document . getElementById ( 'kpiBhytPct' ). innerText = totalKham > 0 ? ( totalBhyt / totalKham * 100 ). toFixed ( 1 ) + '% tổng lượt' : '0%' ;
document . getElementById ( 'kpiThuPhi' ). innerText = fmt ( totalThuPhi );
document . getElementById ( 'kpiThuPhiPct' ). innerText = totalKham > 0 ? ( totalThuPhi / totalKham * 100 ). toFixed ( 1 ) + '% tổng lượt' : '0%' ;
document . getElementById ( 'kpiChuyenVien' ). innerText = fmt ( totalChuyenVien );
document . getElementById ( 'kpiTiemNgua' ). innerText = fmt ( cur . tiemngua );
// BANT
document . getElementById ( 'kpiBantTong' ). innerText = fmt ( cur . bant . tong );
document . getElementById ( 'kpiBantMoi' ). innerText = fmt ( cur . bant . moi );
document . getElementById ( 'kpiBantTk' ). innerText = fmt ( cur . bant . taikham );
// YDCT
document . getElementById ( 'ydctDienCham' ). innerText = fmt ( cur . ydct . dien_cham );
document . getElementById ( 'ydctVltl' ). innerText = fmt ( cur . ydct . vltl );
document . getElementById ( 'ydctSacThuoc' ). innerText = fmt ( cur . ydct . sac_thuoc );
document . getElementById ( 'ydctCayChi' ). innerText = fmt ( cur . ydct . cay_chi );
// Table Khoa
const tbody = document . getElementById ( 'bodyKhoaKcb' );
tbody . innerHTML = '' ;
const compMap = {};
if ( comp && comp . khoa ) {
comp . khoa . forEach ( k => compMap [ k . madv ] = k );
}
if ( ! cur . khoa || cur . khoa . length === 0 ) {
tbody . innerHTML = '<tr><td colspan="8" class="text-center py-8 text-slate-400">Không có dữ liệu khám chữa bệnh trong khoảng thời gian này</td></tr>' ;
} else {
2026-10-27 02:04:23 -07:00
const hideZero = document . getElementById ( 'chkHideZeroRows' ) ? . checked ;
2026-10-26 00:10:07 -07:00
cur . khoa . forEach ( row => {
const k1 = parseInt ( row . tong_kham || 0 , 10 );
2026-10-27 02:04:23 -07:00
const k2 = parseInt ( compMap [ row . madv ] ? . tong_kham || 0 , 10 );
if ( hideZero ) {
if ( isCompare && k1 === 0 && k2 === 0 ) return ;
if ( ! isCompare && k1 === 0 ) return ;
}
2026-10-26 00:10:07 -07:00
let compCols = '' ;
if ( isCompare ) {
const diff = k1 - k2 ;
const pct = k2 > 0 ? (( diff / k2 ) * 100 ). toFixed ( 1 ) + '%' : ( k1 > 0 ? '+100%' : '0%' );
const diffColor = diff > 0 ? 'text-emerald-600 font-bold' : ( diff < 0 ? 'text-rose-600 font-bold' : 'text-slate-500' );
compCols = `
<td class="py-2 px-3 text-right font-medium text-slate-600 th-compare"> ${ fmt ( k2 ) } </td>
<td class="py-2 px-3 text-right ${ diffColor } th-compare"> ${ diff > 0 ? '+' : '' }${ fmt ( diff ) } ( ${ pct } )</td>
` ;
}
tbody . innerHTML += `
<tr class="hover:bg-slate-50 transition-colors">
<td class="py-2 px-3 font-mono text-slate-500 text-xs"> ${ row . madv } </td>
<td class="py-2 px-3 font-semibold text-slate-800"> ${ row . tendv } </td>
<td class="py-2 px-3 text-right text-emerald-700 font-medium"> ${ fmt ( row . bhyt ) } </td>
<td class="py-2 px-3 text-right text-amber-700 font-medium"> ${ fmt ( row . thuphi ) } </td>
<td class="py-2 px-3 text-right text-rose-700 font-medium"> ${ fmt ( row . chuyenvien ) } </td>
<td class="py-2 px-3 text-right font-bold text-blue-700"> ${ fmt ( row . tong_kham ) } </td>
${ compCols }
</tr>
` ;
});
}
// Table Khoa Footer
const tfoot = document . getElementById ( 'footKhoaKcb' );
let compFoot = '' ;
if ( isCompare && comp && comp . khoa ) {
let compTotalKham = 0 ;
comp . khoa . forEach ( k => compTotalKham += parseInt ( k . tong_kham || 0 , 10 ));
const totalDiff = totalKham - compTotalKham ;
const totalPct = compTotalKham > 0 ? (( totalDiff / compTotalKham ) * 100 ). toFixed ( 1 ) + '%' : '0%' ;
compFoot = `
<td class="py-2.5 px-3 text-right th-compare"> ${ fmt ( compTotalKham ) } </td>
<td class="py-2.5 px-3 text-right th-compare"> ${ totalDiff > 0 ? '+' : '' }${ fmt ( totalDiff ) } ( ${ totalPct } )</td>
` ;
}
tfoot . innerHTML = `
<tr>
<td class="py-2.5 px-3" colspan="2">TỔNG CỘNG</td>
<td class="py-2.5 px-3 text-right text-emerald-800"> ${ fmt ( totalBhyt ) } </td>
<td class="py-2.5 px-3 text-right text-amber-800"> ${ fmt ( totalThuPhi ) } </td>
<td class="py-2.5 px-3 text-right text-rose-800"> ${ fmt ( totalChuyenVien ) } </td>
<td class="py-2.5 px-3 text-right text-blue-800"> ${ fmt ( totalKham ) } </td>
${ compFoot }
</tr>
` ;
// CLS Table
const bodyCls = document . getElementById ( 'bodyCls' );
bodyCls . innerHTML = '' ;
if ( ! cur . cls || cur . cls . length === 0 ) {
bodyCls . innerHTML = '<tr><td colspan="4" class="text-center py-6 text-slate-400">Không có chỉ định Cận lâm sàng nào trong kỳ</td></tr>' ;
} else {
cur . cls . forEach ( c => {
const nhomBadge = c . kho === 'XN'
? '<span class="bg-cyan-100 text-cyan-800 px-1.5 py-0.5 rounded font-semibold text-[10px]">Xét nghiệm</span>'
: '<span class="bg-indigo-100 text-indigo-800 px-1.5 py-0.5 rounded font-semibold text-[10px]">CĐHA / TDCN</span>' ;
bodyCls . innerHTML += `
<tr class="hover:bg-slate-50">
<td class="py-1.5 px-3"> ${ nhomBadge } </td>
<td class="py-1.5 px-3 font-medium text-slate-800"> ${ c . tenloai } <span class="text-slate-400 font-mono text-[10px]">( ${ c . maloai } )</span></td>
<td class="py-1.5 px-3 text-right text-slate-600 font-medium"> ${ fmt ( c . so_bn ) } </td>
<td class="py-1.5 px-3 text-right text-blue-700 font-bold"> ${ fmt ( c . so_luot ) } </td>
</tr>
` ;
});
}
// Top 10 Table
const bodyTop10 = document . getElementById ( 'bodyTop10' );
bodyTop10 . innerHTML = '' ;
if ( ! cur . top10 || cur . top10 . length === 0 ) {
bodyTop10 . innerHTML = '<tr><td colspan="3" class="text-center py-6 text-slate-400">Không có dữ liệu chẩn đoán</td></tr>' ;
} else {
cur . top10 . forEach (( item , idx ) => {
bodyTop10 . innerHTML += `
<tr class="hover:bg-slate-50">
<td class="py-1.5 px-2 font-mono font-bold text-rose-600 w-16"> ${ item . maicd } </td>
<td class="py-1.5 px-2 text-slate-700 truncate max-w-xs" title=" ${ item . kqcdoan } "> ${ item . kqcdoan } </td>
<td class="py-1.5 px-2 text-right font-bold text-slate-800 w-16"> ${ fmt ( item . sl ) } </td>
</tr>
` ;
});
}
// Render Outpatient Chart
renderChartNgoaiTru ();
}
// Render Chart Ngoại Trú (Chart.js)
function renderChartNgoaiTru () {
if ( ! rawNgoaiTruData ) return ;
const canvas = document . getElementById ( 'chartNgoaiTru' );
if ( ! canvas ) return ;
const ctx = canvas . getContext ( '2d' );
const cur = rawNgoaiTruData . current || {};
const comp = rawNgoaiTruData . compare ;
if ( chartNgoaiTruInstance ) {
chartNgoaiTruInstance . destroy ();
chartNgoaiTruInstance = null ;
}
const khoaList = Array . isArray ( cur . khoa ) ? cur . khoa : [];
const clsList = Array . isArray ( cur . cls ) ? cur . cls : [];
2026-10-27 02:04:23 -07:00
// 1. Biểu đồ tròn % khám theo từng khoa (ISS-20261026-002)
if ( currentChartTypeNgoaiTru === 'khoa-pie' ) {
const top6 = khoaList . slice ( 0 , 6 );
const otherSum = khoaList . slice ( 6 ). reduce (( acc , k ) => acc + parseInt ( k . tong_kham || 0 , 10 ), 0 );
const labels = top6 . map ( k => k . tendv );
const data = top6 . map ( k => parseInt ( k . tong_kham || 0 , 10 ));
if ( otherSum > 0 ) {
labels . push ( 'Các khoa khác' );
data . push ( otherSum );
}
const colors = [
'rgba(37, 99, 235, 0.85)' ,
'rgba(16, 185, 129, 0.85)' ,
'rgba(245, 158, 11, 0.85)' ,
'rgba(236, 72, 153, 0.85)' ,
'rgba(99, 102, 241, 0.85)' ,
'rgba(6, 182, 212, 0.85)' ,
'rgba(148, 163, 184, 0.85)'
];
chartNgoaiTruInstance = new Chart ( ctx , {
type : 'doughnut' ,
data : {
labels : labels ,
datasets : [{
data : data ,
backgroundColor : colors . slice ( 0 , labels . length ),
borderWidth : 2 ,
borderColor : '#ffffff'
}]
},
options : {
responsive : true ,
maintainAspectRatio : false ,
plugins : {
legend : {
position : 'right' ,
labels : { font : { size : 11 , family : 'Inter, sans-serif' }, boxWidth : 14 }
},
tooltip : {
callbacks : {
label : function ( context ) {
const total = context . dataset . data . reduce (( a , b ) => a + b , 0 );
const val = context . raw || 0 ;
const pct = total > 0 ? (( val / total ) * 100 ). toFixed ( 1 ) : 0 ;
return ` ${ context . label } : ${ fmt ( val ) } lượt ( ${ pct } %)` ;
}
}
}
}
}
});
return ;
}
// 2. Biểu đồ cột nhóm BHYT vs Thu phí (ISS-20261026-002)
if ( currentChartTypeNgoaiTru === 'bhyt-group' ) {
const topKhoa = khoaList . slice ( 0 , 7 );
const labels = topKhoa . map ( k => k . tendv );
const dataBhyt = topKhoa . map ( k => parseInt ( k . bhyt || 0 , 10 ));
const dataThuPhi = topKhoa . map ( k => parseInt ( k . thuphi || 0 , 10 ));
chartNgoaiTruInstance = new Chart ( ctx , {
type : 'bar' ,
data : {
labels : labels ,
datasets : [
{
label : 'Khám BHYT' ,
data : dataBhyt ,
backgroundColor : 'rgba(16, 185, 129, 0.85)' ,
borderRadius : 4
},
{
label : 'Khám Thu Phí / Viện Phí' ,
data : dataThuPhi ,
backgroundColor : 'rgba(245, 158, 11, 0.85)' ,
borderRadius : 4
}
]
},
options : {
responsive : true ,
maintainAspectRatio : false ,
plugins : {
legend : {
position : 'top' ,
labels : { font : { size : 11 , family : 'Inter, sans-serif' } }
}
},
scales : {
y : { beginAtZero : true , ticks : { font : { size : 10 } } },
x : { ticks : { font : { size : 10 }, maxRotation : 25 , minRotation : 0 } }
}
}
});
return ;
}
// 3. Cột Khoa hoặc CLS thông thường
2026-10-26 00:10:07 -07:00
let labels = [];
let data1 = [];
let data2 = [];
if ( currentChartTypeNgoaiTru === 'khoa' ) {
labels = khoaList . slice ( 0 , 7 ). map ( k => k . tendv );
data1 = khoaList . slice ( 0 , 7 ). map ( k => parseInt ( k . tong_kham || 0 , 10 ));
if ( comp && Array . isArray ( comp . khoa )) {
const compMap = {};
comp . khoa . forEach ( k => compMap [ k . madv ] = k );
data2 = khoaList . slice ( 0 , 7 ). map ( k => parseInt ( compMap [ k . madv ] ? . tong_kham || 0 , 10 ));
}
} else {
labels = clsList . slice ( 0 , 8 ). map ( c => c . tenloai );
data1 = clsList . slice ( 0 , 8 ). map ( c => parseInt ( c . so_luot || 0 , 10 ));
if ( comp && Array . isArray ( comp . cls )) {
const compMap = {};
comp . cls . forEach ( c => compMap [ c . maloai ] = c );
data2 = clsList . slice ( 0 , 8 ). map ( c => parseInt ( compMap [ c . maloai ] ? . so_luot || 0 , 10 ));
}
}
const datasets = [
{
label : comp ? 'Kỳ hiện tại' : 'Lượt thực hiện' ,
data : data1 ,
backgroundColor : 'rgba(37, 99, 235, 0.85)' ,
borderRadius : 4 ,
}
];
if ( comp ) {
datasets . push ({
label : 'Kỳ so sánh' ,
data : data2 ,
backgroundColor : 'rgba(234, 88, 12, 0.85)' ,
borderRadius : 4 ,
});
}
chartNgoaiTruInstance = new Chart ( ctx , {
type : 'bar' ,
data : {
labels : labels ,
datasets : datasets ,
},
options : {
responsive : true ,
maintainAspectRatio : false ,
plugins : {
legend : {
position : 'top' ,
labels : { font : { size : 11 , family : 'Inter, sans-serif' } },
},
},
scales : {
y : {
beginAtZero : true ,
ticks : { font : { size : 10 } },
},
x : {
ticks : {
font : { size : 10 },
maxRotation : 30 ,
minRotation : 0 ,
},
},
},
},
});
}
// ========================================================
// RENDER NỘI TRÚ
// ========================================================
function renderNoiTru () {
if ( ! rawNoiTruData ) return ;
const cur = rawNoiTruData . current || {};
const comp = rawNoiTruData . compare ;
let totalCu = 0 , totalMoi = 0 , totalOn = 0 , totalNang = 0 , totalTron = 0 , totalCv = 0 , totalTv = 0 , totalHienCon = 0 , totalTd = 0 , totalCs1 = 0 ;
const tbody = document . getElementById ( 'bodyNoiTru' );
tbody . innerHTML = '' ;
const khoaList = Array . isArray ( cur . khoa ) ? cur . khoa : [];
if ( khoaList . length === 0 ) {
tbody . innerHTML = '<tr><td colspan="12" class="text-center py-8 text-slate-400">Không có dữ liệu nội trú trong khoảng thời gian này</td></tr>' ;
} else {
2026-10-27 02:04:23 -07:00
const hideZero = document . getElementById ( 'chkHideZeroRows' ) ? . checked ;
2026-10-26 00:10:07 -07:00
khoaList . forEach ( row => {
const cu = parseInt ( row . benh_cu || 0 , 10 );
const moi = parseInt ( row . benh_moi || 0 , 10 );
const on = parseInt ( row . on_xin_ve || 0 , 10 );
const nang = parseInt ( row . nang_xin_ve || 0 , 10 );
const tron = parseInt ( row . tron_vien || 0 , 10 );
const cv = parseInt ( row . chuyen_vien || 0 , 10 );
const tv = parseInt ( row . tu_vong || 0 , 10 );
const hiencon = parseInt ( row . benh_hien_con || 0 , 10 );
const td = parseInt ( row . benh_theo_doi || 0 , 10 );
const cs1 = parseInt ( row . chamsoc_cap1 || 0 , 10 );
totalCu += cu ; totalMoi += moi ; totalOn += on ; totalNang += nang ;
totalTron += tron ; totalCv += cv ; totalTv += tv ;
totalHienCon += hiencon ; totalTd += td ; totalCs1 += cs1 ;
2026-10-27 02:04:23 -07:00
if ( hideZero ) {
const rowSum = cu + moi + on + nang + tron + cv + tv + hiencon + td + cs1 ;
if ( rowSum === 0 ) return ;
}
2026-10-26 00:10:07 -07:00
tbody . innerHTML += `
<tr class="hover:bg-slate-50 transition-colors">
<td class="py-2 px-3 font-mono text-slate-500"> ${ row . madv } </td>
<td class="py-2 px-3 font-semibold text-slate-800"> ${ row . tendv } </td>
<td class="py-2 px-3 text-right text-slate-600 font-medium"> ${ fmt ( cu ) } </td>
<td class="py-2 px-3 text-right text-blue-700 font-bold"> ${ fmt ( moi ) } </td>
<td class="py-2 px-3 text-right text-emerald-700 font-medium"> ${ fmt ( on ) } </td>
<td class="py-2 px-3 text-right text-amber-700 font-medium"> ${ fmt ( nang ) } </td>
<td class="py-2 px-3 text-right text-slate-600"> ${ fmt ( tron ) } </td>
<td class="py-2 px-3 text-right text-rose-700 font-medium"> ${ fmt ( cv ) } </td>
<td class="py-2 px-3 text-right text-rose-900 font-bold"> ${ fmt ( tv ) } </td>
<td class="py-2 px-3 text-right text-indigo-700 font-extrabold bg-blue-50/50"> ${ fmt ( hiencon ) } </td>
<td class="py-2 px-3 text-right text-rose-600 font-semibold"> ${ fmt ( td ) } </td>
<td class="py-2 px-3 text-right text-amber-600 font-semibold"> ${ fmt ( cs1 ) } </td>
</tr>
` ;
});
}
// KPI Nội Trú
const totalXuatVien = totalOn + totalNang + totalTron + totalCv + totalTv ;
document . getElementById ( 'kpiNtBenhCu' ). innerText = fmt ( totalCu );
document . getElementById ( 'kpiNtBenhMoi' ). innerText = fmt ( totalMoi );
document . getElementById ( 'kpiNtXuatVien' ). innerText = fmt ( totalXuatVien );
document . getElementById ( 'kpiNtOnPct' ). innerText = `Ổn về: ${ fmt ( totalOn ) } ca` ;
document . getElementById ( 'kpiNtHienCon' ). innerText = fmt ( totalHienCon );
document . getElementById ( 'kpiNtTheoDoi' ). innerText = fmt ( totalTd );
document . getElementById ( 'kpiNtCsCap1' ). innerText = fmt ( totalCs1 );
// Footer NoiTru Table
document . getElementById ( 'footNoiTru' ). innerHTML = `
<tr>
<td class="py-2.5 px-3" colspan="2">TỔNG CỘNG</td>
<td class="py-2.5 px-3 text-right"> ${ fmt ( totalCu ) } </td>
<td class="py-2.5 px-3 text-right text-blue-800"> ${ fmt ( totalMoi ) } </td>
<td class="py-2.5 px-3 text-right text-emerald-800"> ${ fmt ( totalOn ) } </td>
<td class="py-2.5 px-3 text-right text-amber-800"> ${ fmt ( totalNang ) } </td>
<td class="py-2.5 px-3 text-right"> ${ fmt ( totalTron ) } </td>
<td class="py-2.5 px-3 text-right text-rose-800"> ${ fmt ( totalCv ) } </td>
<td class="py-2.5 px-3 text-right text-rose-900"> ${ fmt ( totalTv ) } </td>
<td class="py-2.5 px-3 text-right text-indigo-800"> ${ fmt ( totalHienCon ) } </td>
<td class="py-2.5 px-3 text-right text-rose-800"> ${ fmt ( totalTd ) } </td>
<td class="py-2.5 px-3 text-right text-amber-800"> ${ fmt ( totalCs1 ) } </td>
</tr>
` ;
// Render Inpatient Chart
renderChartNoiTru ();
}
2026-10-27 02:04:23 -07:00
// Render Chart Nội Trú (ISS-20261027-001)
2026-10-26 00:10:07 -07:00
function renderChartNoiTru () {
if ( ! rawNoiTruData ) return ;
const canvas = document . getElementById ( 'chartNoiTru' );
if ( ! canvas ) return ;
const ctx = canvas . getContext ( '2d' );
const cur = rawNoiTruData . current || {};
const comp = rawNoiTruData . compare ;
2026-10-27 02:04:23 -07:00
const subTitle = document . getElementById ( 'subTitleChartNoiTru' );
2026-10-26 00:10:07 -07:00
if ( chartNoiTruInstance ) {
chartNoiTruInstance . destroy ();
chartNoiTruInstance = null ;
}
const khoaList = Array . isArray ( cur . khoa ) ? cur . khoa : [];
2026-10-27 02:04:23 -07:00
// 1. Biểu đồ tròn % 4 tiêu chí toàn viện (Bệnh cũ, Mới, Xuất viện, Hiện còn)
if ( currentChartTypeNoiTru === 'noitru-pie-tong' ) {
if ( subTitle ) subTitle . innerText = 'Tỷ lệ cơ cấu 4 tiêu chí luân chuyển bệnh nhân toàn viện' ;
let cu = 0 , moi = 0 , xuat = 0 , hiencon = 0 ;
khoaList . forEach ( k => {
cu += parseInt ( k . benh_cu || 0 , 10 );
moi += parseInt ( k . benh_moi || 0 , 10 );
xuat += (
parseInt ( k . on_xin_ve || 0 , 10 ) +
parseInt ( k . nang_xin_ve || 0 , 10 ) +
parseInt ( k . tron_vien || 0 , 10 ) +
parseInt ( k . chuyen_vien || 0 , 10 ) +
parseInt ( k . tu_vong || 0 , 10 )
);
hiencon += parseInt ( k . benh_hien_con || 0 , 10 );
});
chartNoiTruInstance = new Chart ( ctx , {
type : 'doughnut' ,
data : {
labels : [ 'Bệnh cũ đầu kỳ' , 'Bệnh mới vào viện' , 'Tổng xuất viện' , 'Hiện còn điều trị' ],
datasets : [{
data : [ cu , moi , xuat , hiencon ],
backgroundColor : [
'rgba(148, 163, 184, 0.85)' ,
'rgba(37, 99, 235, 0.85)' ,
'rgba(16, 185, 129, 0.85)' ,
'rgba(99, 102, 241, 0.85)'
],
borderWidth : 2 ,
borderColor : '#ffffff'
}]
},
options : {
responsive : true ,
maintainAspectRatio : false ,
plugins : {
legend : { position : 'right' , labels : { font : { size : 11 , family : 'Inter, sans-serif' }, boxWidth : 14 } },
tooltip : {
callbacks : {
label : function ( context ) {
const total = context . dataset . data . reduce (( a , b ) => a + b , 0 );
const val = context . raw || 0 ;
const pct = total > 0 ? (( val / total ) * 100 ). toFixed ( 1 ) : 0 ;
return ` ${ context . label } : ${ fmt ( val ) } ca ( ${ pct } %)` ;
}
}
}
}
}
});
return ;
}
// 2. Biểu đồ tròn % bệnh hiện còn theo từng khoa
if ( currentChartTypeNoiTru === 'noitru-pie-khoa' ) {
if ( subTitle ) subTitle . innerText = '% Phân bố bệnh nhân hiện còn đang điều trị theo từng khoa' ;
const sorted = [... khoaList ]. sort (( a , b ) => parseInt ( b . benh_hien_con || 0 , 10 ) - parseInt ( a . benh_hien_con || 0 , 10 ));
const top6 = sorted . slice ( 0 , 6 );
const otherSum = sorted . slice ( 6 ). reduce (( acc , k ) => acc + parseInt ( k . benh_hien_con || 0 , 10 ), 0 );
const labels = top6 . map ( k => k . tendv );
const data = top6 . map ( k => parseInt ( k . benh_hien_con || 0 , 10 ));
if ( otherSum > 0 ) {
labels . push ( 'Các khoa khác' );
data . push ( otherSum );
}
const colors = [
'rgba(99, 102, 241, 0.85)' ,
'rgba(37, 99, 235, 0.85)' ,
'rgba(16, 185, 129, 0.85)' ,
'rgba(245, 158, 11, 0.85)' ,
'rgba(236, 72, 153, 0.85)' ,
'rgba(6, 182, 212, 0.85)' ,
'rgba(148, 163, 184, 0.85)'
];
chartNoiTruInstance = new Chart ( ctx , {
type : 'doughnut' ,
data : {
labels : labels ,
datasets : [{
data : data ,
backgroundColor : colors . slice ( 0 , labels . length ),
borderWidth : 2 ,
borderColor : '#ffffff'
}]
},
options : {
responsive : true ,
maintainAspectRatio : false ,
plugins : {
legend : { position : 'right' , labels : { font : { size : 11 , family : 'Inter, sans-serif' }, boxWidth : 14 } },
tooltip : {
callbacks : {
label : function ( context ) {
const total = context . dataset . data . reduce (( a , b ) => a + b , 0 );
const val = context . raw || 0 ;
const pct = total > 0 ? (( val / total ) * 100 ). toFixed ( 1 ) : 0 ;
return ` ${ context . label } : ${ fmt ( val ) } người ( ${ pct } %)` ;
}
}
}
}
}
});
return ;
}
// 3. Biểu đồ cột nhóm 4 tiêu chí theo khoa (Bệnh cũ, Bệnh mới, Xuất viện, Hiện còn)
if ( currentChartTypeNoiTru === 'noitru-group' ) {
if ( subTitle ) subTitle . innerText = 'Cột nhóm đối chiếu 4 tiêu chí chi tiết theo từng khoa' ;
const topKhoa = khoaList . slice ( 0 , 6 );
const labels = topKhoa . map ( k => k . tendv );
const dataCu = topKhoa . map ( k => parseInt ( k . benh_cu || 0 , 10 ));
const dataMoi = topKhoa . map ( k => parseInt ( k . benh_moi || 0 , 10 ));
const dataXuat = topKhoa . map ( k => (
parseInt ( k . on_xin_ve || 0 , 10 ) +
parseInt ( k . nang_xin_ve || 0 , 10 ) +
parseInt ( k . tron_vien || 0 , 10 ) +
parseInt ( k . chuyen_vien || 0 , 10 ) +
parseInt ( k . tu_vong || 0 , 10 )
));
const dataHienCon = topKhoa . map ( k => parseInt ( k . benh_hien_con || 0 , 10 ));
chartNoiTruInstance = new Chart ( ctx , {
type : 'bar' ,
data : {
labels : labels ,
datasets : [
{ label : 'Bệnh cũ' , data : dataCu , backgroundColor : 'rgba(148, 163, 184, 0.85)' , borderRadius : 3 },
{ label : 'Bệnh mới' , data : dataMoi , backgroundColor : 'rgba(37, 99, 235, 0.85)' , borderRadius : 3 },
{ label : 'Xuất viện' , data : dataXuat , backgroundColor : 'rgba(16, 185, 129, 0.85)' , borderRadius : 3 },
{ label : 'Hiện còn' , data : dataHienCon , backgroundColor : 'rgba(99, 102, 241, 0.85)' , borderRadius : 3 }
]
},
options : {
responsive : true ,
maintainAspectRatio : false ,
plugins : {
legend : { position : 'top' , labels : { font : { size : 11 , family : 'Inter, sans-serif' } } }
},
scales : {
y : { beginAtZero : true },
x : { ticks : { maxRotation : 25 , minRotation : 0 , font : { size : 10 } } }
}
}
});
return ;
}
// 4. Mặc định: Cột vào viện & ra viện (noitru-col)
if ( subTitle ) subTitle . innerText = 'Phân bố lưu lượng và cơ cấu bệnh nhân điều trị' ;
2026-10-26 00:10:07 -07:00
const topKhoa = khoaList . slice ( 0 , 8 );
const labels = topKhoa . map ( k => k . tendv );
const dataMoi = topKhoa . map ( k => parseInt ( k . benh_moi || 0 , 10 ));
const dataRaVien = topKhoa . map ( k => (
parseInt ( k . on_xin_ve || 0 , 10 ) +
parseInt ( k . nang_xin_ve || 0 , 10 ) +
parseInt ( k . tron_vien || 0 , 10 ) +
parseInt ( k . chuyen_vien || 0 , 10 ) +
parseInt ( k . tu_vong || 0 , 10 )
));
const dataHienCon = topKhoa . map ( k => parseInt ( k . benh_hien_con || 0 , 10 ));
const datasets = [
{
label : 'Bệnh mới vào viện' ,
data : dataMoi ,
backgroundColor : 'rgba(37, 99, 235, 0.85)' ,
borderRadius : 4 ,
},
{
label : 'Tổng xuất viện' ,
data : dataRaVien ,
backgroundColor : 'rgba(16, 185, 129, 0.85)' ,
borderRadius : 4 ,
},
{
label : 'Hiện còn đang điều trị' ,
data : dataHienCon ,
backgroundColor : 'rgba(99, 102, 241, 0.85)' ,
borderRadius : 4 ,
}
];
if ( comp && Array . isArray ( comp . khoa )) {
const compMap = {};
comp . khoa . forEach ( k => compMap [ k . madv ] = k );
const dataMoiComp = topKhoa . map ( k => parseInt ( compMap [ k . madv ] ? . benh_moi || 0 , 10 ));
datasets . push ({
label : 'Bệnh mới (Kỳ so sánh)' ,
data : dataMoiComp ,
backgroundColor : 'rgba(234, 88, 12, 0.85)' ,
borderRadius : 4 ,
});
}
chartNoiTruInstance = new Chart ( ctx , {
type : 'bar' ,
data : {
labels : labels ,
datasets : datasets ,
},
options : {
responsive : true ,
maintainAspectRatio : false ,
plugins : {
legend : {
position : 'top' ,
labels : { font : { size : 11 , family : 'Inter, sans-serif' } },
},
},
scales : {
y : { beginAtZero : true },
x : { ticks : { maxRotation : 25 , minRotation : 0 } },
},
},
});
}
// ========================================================
// RENDER BẢNG 3: BỆNH NHÂN CHUYỂN VIỆN
// ========================================================
function renderBang3 ( filterDept ) {
const tbody = document . getElementById ( 'bodyBang3' );
tbody . innerHTML = '' ;
const filtered = filterDept === 'ALL'
? rawBang3Data
: rawBang3Data . filter ( item => item . tendv === filterDept );
document . getElementById ( 'countBang3' ). innerText = ` ${ filtered . length } ca` ;
if ( filtered . length === 0 ) {
tbody . innerHTML = `<tr><td colspan="9" class="text-center py-6 text-slate-400">Không có bệnh nhân chuyển viện trong thời gian này</td></tr>` ;
return ;
}
filtered . forEach (( row , index ) => {
tbody . innerHTML += `
<tr class="hover:bg-slate-50 transition-colors">
<td class="py-2.5 px-2.5 text-center font-bold text-slate-400"> ${ index + 1 } </td>
<td class="py-2.5 px-2.5">
<div class="font-mono font-bold text-blue-700"> ${ row . mabn } </div>
<div class="font-mono text-[10px] text-slate-400"> ${ row . maba } </div>
</td>
<td class="py-2.5 px-3 font-bold text-slate-800"> ${ row . hoten } </td>
<td class="py-2.5 px-3 text-slate-600"> ${ row . diachi } </td>
<td class="py-2.5 px-3 text-slate-500">
<div><i class="fa-solid fa-arrow-right-to-bracket text-emerald-500"></i> ${ row . ngayvv } </div>
<div><i class="fa-solid fa-arrow-right-from-bracket text-rose-500"></i> ${ row . ngayrv } </div>
</td>
<td class="py-2.5 px-3 font-medium text-slate-700"> ${ row . tendv } </td>
<td class="py-2.5 px-3">
<div class="font-semibold text-rose-700"> ${ row . chandoan_chinh } </div>
${ row . chandoan_phu ? `<div class="text-[11px] text-slate-500 mt-0.5"> ${ row . chandoan_phu } </div>` : '' }
</td>
<td class="py-2.5 px-3 text-slate-600 text-[11px] leading-relaxed max-w-xs break-words"> ${ row . ketqua_cls } </td>
<td class="py-2.5 px-3 bg-amber-50/50 border-l border-amber-200">
<textarea
class="table-cell-edit text-xs"
rows="2"
placeholder="Nhập ghi chú giao ban..."
data-maba=" ${ row . maba } "
data-mabn=" ${ row . mabn } "
data-loai="CHUYEN_VIEN"
> ${ row . ghichu || '' } </textarea>
</td>
</tr>
` ;
});
attachNoteHandlers ();
}
// ========================================================
// RENDER BẢNG 4: BỆNH NHÂN THEO DÕI & CS CẤP I
// ========================================================
function renderBang4 ( filterDept ) {
const tbody = document . getElementById ( 'bodyBang4' );
tbody . innerHTML = '' ;
const filtered = filterDept === 'ALL'
? rawBang4Data
: rawBang4Data . filter ( item => item . tendv === filterDept );
document . getElementById ( 'countBang4' ). innerText = ` ${ filtered . length } ca` ;
if ( filtered . length === 0 ) {
tbody . innerHTML = `<tr><td colspan="9" class="text-center py-6 text-slate-400">Không có bệnh nhân cần theo dõi hoặc CS Cấp I</td></tr>` ;
return ;
}
filtered . forEach (( row , index ) => {
tbody . innerHTML += `
<tr class="hover:bg-slate-50 transition-colors">
<td class="py-2.5 px-2.5 text-center font-bold text-slate-400"> ${ index + 1 } </td>
<td class="py-2.5 px-2.5">
<div class="font-mono font-bold text-blue-700"> ${ row . mabn } </div>
<div class="font-mono text-[10px] text-slate-400"> ${ row . maba } </div>
</td>
<td class="py-2.5 px-3 font-bold text-slate-800"> ${ row . hoten } </td>
<td class="py-2.5 px-3 text-slate-600"> ${ row . diachi } </td>
<td class="py-2.5 px-3 text-slate-500"> ${ row . ngayvv } </td>
<td class="py-2.5 px-3 font-medium text-slate-700">
<div> ${ row . tendv } </div>
<span class="inline-block bg-amber-100 text-amber-800 px-1.5 py-0.5 rounded text-[10px] font-semibold mt-1">
${ row . chamsoc }
</span>
</td>
<td class="py-2.5 px-3">
<div class="font-semibold text-rose-700"> ${ row . chandoan_chinh } </div>
${ row . chandoan_phu ? `<div class="text-[11px] text-slate-500 mt-0.5"> ${ row . chandoan_phu } </div>` : '' }
</td>
<td class="py-2.5 px-3 text-slate-600 text-[11px] leading-relaxed max-w-xs break-words"> ${ row . ketqua_cls } </td>
<td class="py-2.5 px-3 bg-amber-50/50 border-l border-amber-200">
<textarea
class="table-cell-edit text-xs"
rows="2"
placeholder="Nhập ghi chú giao ban..."
data-maba=" ${ row . maba } "
data-mabn=" ${ row . mabn } "
data-loai="THEO_DOI"
> ${ row . ghichu || '' } </textarea>
</td>
</tr>
` ;
});
attachNoteHandlers ();
}
// INLINE NOTE AUTO-SAVE HANDLER
function attachNoteHandlers () {
document . querySelectorAll ( '.table-cell-edit' ). forEach ( el => {
el . removeEventListener ( 'change' , handleNoteChange );
el . addEventListener ( 'change' , handleNoteChange );
});
}
async function handleNoteChange ( e ) {
const el = e . target ;
const maba = el . getAttribute ( 'data-maba' );
const mabn = el . getAttribute ( 'data-mabn' );
const loai = el . getAttribute ( 'data-loai' );
const ghichu = el . value . trim ();
const database = document . getElementById ( 'selectDb' ). value ;
el . classList . add ( 'saving' );
try {
const res = await fetch ( ` ${ API_BASE } /api/cap-nhat-ghi-chu` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ maba , mabn , loai , ghichu , database }),
});
const result = await res . json ();
if ( result . success ) {
el . classList . remove ( 'saving' );
el . classList . add ( 'saved' );
showToast ( `Đã lưu ghi chú BN ${ mabn } ` );
setTimeout (() => el . classList . remove ( 'saved' ), 2000 );
} else {
throw new Error ( result . message );
}
} catch ( err ) {
el . classList . remove ( 'saving' );
alert ( 'Lỗi lưu ghi chú: ' + err . message );
}
}
// Export Excel Helper
function exportExcel () {
const activeTab = document . querySelector ( '.tab-btn.active' ). getAttribute ( 'data-tab' );
const table = activeTab === 'tab-ngoaitru'
? document . getElementById ( 'tableKhoaKcb' )
: document . getElementById ( 'tableNoiTru' );
let html = table . outerHTML ;
let blob = new Blob ([ '\ufeff' + html ], { type : 'application/vnd.ms-excel' });
let url = URL . createObjectURL ( blob );
let a = document . createElement ( 'a' );
a . href = url ;
a . download = `BaoCaoGiaoBan_ ${ activeTab } _ ${ Date . now () } .xls` ;
document . body . appendChild ( a );
a . click ();
document . body . removeChild ( a );
showToast ( 'Đã xuất tập tin Excel thành công!' );
}
// ========================================================
// CHỨC NĂNG QUẢN LÝ ISSUE, BÁO LỖI & LƯU FILE TỔNG HỢP
// ========================================================
function initIssueFeatures () {
// Modal Open / Close
const btnOpen1 = document . getElementById ( 'btnOpenIssueModal' );
const btnOpen2 = document . getElementById ( 'btnOpenIssueModal2' );
const btnClose = document . getElementById ( 'btnCloseIssueModal' );
const btnCancel = document . getElementById ( 'btnCancelIssue' );
if ( btnOpen1 ) btnOpen1 . addEventListener ( 'click' , openCreateIssueModal );
if ( btnOpen2 ) btnOpen2 . addEventListener ( 'click' , openCreateIssueModal );
if ( btnClose ) btnClose . addEventListener ( 'click' , closeCreateIssueModal );
if ( btnCancel ) btnCancel . addEventListener ( 'click' , closeCreateIssueModal );
// Form Submit
const formCreate = document . getElementById ( 'formCreateIssue' );
if ( formCreate ) formCreate . addEventListener ( 'submit' , handleCreateIssue );
// Update Modal
const btnCloseUp = document . getElementById ( 'btnCloseUpdateModal' );
const btnCancelUp = document . getElementById ( 'btnCancelUpdate' );
if ( btnCloseUp ) btnCloseUp . addEventListener ( 'click' , closeUpdateModal );
if ( btnCancelUp ) btnCancelUp . addEventListener ( 'click' , closeUpdateModal );
const formUpdate = document . getElementById ( 'formUpdateIssue' );
if ( formUpdate ) formUpdate . addEventListener ( 'submit' , handleUpdateIssue );
// Lightbox
const btnCloseLb = document . getElementById ( 'btnCloseLightbox' );
const modalLb = document . getElementById ( 'modalImagePreview' );
if ( btnCloseLb ) btnCloseLb . addEventListener ( 'click' , closeLightbox );
if ( modalLb ) {
modalLb . addEventListener ( 'click' , ( e ) => {
if ( e . target === modalLb ) closeLightbox ();
});
}
// File Upload & Paste Zone
const pasteZone = document . getElementById ( 'issuePasteZone' );
const fileInput = document . getElementById ( 'issueFileInput' );
const btnRemove = document . getElementById ( 'btnRemoveFile' );
if ( pasteZone ) {
pasteZone . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '#btnRemoveFile' )) return ;
fileInput . click ();
});
}
if ( fileInput ) {
fileInput . addEventListener ( 'change' , () => {
if ( fileInput . files && fileInput . files [ 0 ]) {
setIssueFile ( fileInput . files [ 0 ]);
}
});
}
if ( btnRemove ) {
btnRemove . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ();
clearIssueFile ();
});
}
// Clipboard Paste (Ctrl+V) anywhere inside the modal or page
document . addEventListener ( 'paste' , ( e ) => {
const modal = document . getElementById ( 'modalIssue' );
if ( ! modal || modal . classList . contains ( 'hidden' )) return ;
const items = e . clipboardData ? . items ;
if ( ! items ) return ;
for ( let i = 0 ; i < items . length ; i ++ ) {
if ( items [ i ]. type . indexOf ( 'image' ) !== - 1 ) {
const blob = items [ i ]. getAsFile ();
if ( blob ) {
const ext = blob . type . split ( '/' )[ 1 ] || 'png' ;
const file = new File ([ blob ], `screenshot_ ${ Date . now () } . ${ ext } ` , { type : blob . type });
setIssueFile ( file );
showToast ( 'Đã dán ảnh chụp màn hình từ Clipboard!' );
break ;
}
}
}
});
// Filters
const fStatus = document . getElementById ( 'filterIssueStatus' );
const fCat = document . getElementById ( 'filterIssueCategory' );
const fKeyword = document . getElementById ( 'filterIssueKeyword' );
if ( fStatus ) fStatus . addEventListener ( 'change' , renderIssuesTable );
if ( fCat ) fCat . addEventListener ( 'change' , renderIssuesTable );
if ( fKeyword ) fKeyword . addEventListener ( 'input' , renderIssuesTable );
// Export Excel
const btnExportIssues = document . getElementById ( 'btnExportIssuesExcel' );
if ( btnExportIssues ) btnExportIssues . addEventListener ( 'click' , exportIssuesExcel );
}
function setIssueFile ( file ) {
currentIssueFile = file ;
const prompt = document . getElementById ( 'pasteZonePrompt' );
const preview = document . getElementById ( 'issuePreviewArea' );
const nameEl = document . getElementById ( 'issueFileName' );
const sizeEl = document . getElementById ( 'issueFileSize' );
const imgEl = document . getElementById ( 'issueImgPreview' );
const fileIcon = document . getElementById ( 'issueFileIcon' );
prompt . classList . add ( 'hidden' );
preview . classList . remove ( 'hidden' );
nameEl . innerText = file . name ;
sizeEl . innerText = ` ${ ( file . size / 1024 ). toFixed ( 1 ) } KB` ;
if ( file . type . startsWith ( 'image/' )) {
imgEl . classList . remove ( 'hidden' );
fileIcon . classList . add ( 'hidden' );
const reader = new FileReader ();
reader . onload = ( e ) => {
imgEl . src = e . target . result ;
};
reader . readAsDataURL ( file );
} else {
imgEl . classList . add ( 'hidden' );
fileIcon . classList . remove ( 'hidden' );
}
}
function clearIssueFile () {
currentIssueFile = null ;
const fileInput = document . getElementById ( 'issueFileInput' );
if ( fileInput ) fileInput . value = '' ;
const prompt = document . getElementById ( 'pasteZonePrompt' );
const preview = document . getElementById ( 'issuePreviewArea' );
prompt . classList . remove ( 'hidden' );
preview . classList . add ( 'hidden' );
}
function openCreateIssueModal () {
const selectDb = document . getElementById ( 'selectDb' );
const dbName = selectDb . selectedOptions [ 0 ] ? . textContent || selectDb . value ;
const dateFrom = document . getElementById ( 'dateFrom' ). value ;
const dateTo = document . getElementById ( 'dateTo' ). value ;
document . getElementById ( 'issueDatabase' ). value = ` ${ dbName } ( ${ selectDb . value } )` ;
document . getElementById ( 'issuePeriod' ). value = `Từ ${ dateFrom } đến ${ dateTo } ` ;
document . getElementById ( 'issueTitle' ). value = '' ;
document . getElementById ( 'issueDescription' ). value = '' ;
clearIssueFile ();
const modal = document . getElementById ( 'modalIssue' );
modal . classList . remove ( 'hidden' );
}
function closeCreateIssueModal () {
const modal = document . getElementById ( 'modalIssue' );
modal . classList . add ( 'hidden' );
}
async function handleCreateIssue ( e ) {
e . preventDefault ();
const btnSubmit = document . getElementById ( 'btnSubmitIssue' );
btnSubmit . disabled = true ;
btnSubmit . innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> <span>Đang lưu...</span>' ;
try {
const selectDb = document . getElementById ( 'selectDb' );
const dbValue = selectDb . value ;
const dbName = selectDb . selectedOptions [ 0 ] ? . textContent || dbValue ;
const formData = new FormData ();
formData . append ( 'title' , document . getElementById ( 'issueTitle' ). value );
formData . append ( 'category' , document . getElementById ( 'issueCategory' ). value );
formData . append ( 'module' , document . getElementById ( 'issueModule' ). value );
formData . append ( 'priority' , document . getElementById ( 'issuePriority' ). value );
formData . append ( 'database' , dbValue );
formData . append ( 'databaseName' , dbName );
formData . append ( 'period' , document . getElementById ( 'issuePeriod' ). value );
formData . append ( 'reporter' , document . getElementById ( 'issueReporter' ). value );
formData . append ( 'department' , document . getElementById ( 'issueDepartment' ). value );
formData . append ( 'description' , document . getElementById ( 'issueDescription' ). value );
if ( currentIssueFile ) {
formData . append ( 'attachment' , currentIssueFile );
}
const res = await fetch ( ` ${ API_BASE } /api/issues` , {
method : 'POST' ,
body : formData ,
});
const result = await res . json ();
if ( result . success ) {
showToast ( 'Đã lưu báo lỗi & tệp thành công!' );
closeCreateIssueModal ();
await loadIssues ( true );
} else {
throw new Error ( result . message );
}
} catch ( err ) {
alert ( 'Lỗi gửi báo lỗi: ' + err . message );
} finally {
btnSubmit . disabled = false ;
btnSubmit . innerHTML = '<i class="fa-solid fa-paper-plane"></i> <span>Gửi Báo Lỗi & Lưu File</span>' ;
}
}
async function loadIssues ( shouldShowToast = false ) {
try {
const res = await fetch ( ` ${ API_BASE } /api/issues` );
const result = await res . json ();
if ( result . success && Array . isArray ( result . data )) {
rawIssuesData = result . data ;
updateIssueKpis ();
renderIssuesTable ();
if ( shouldShowToast ) {
showToast ( `Đã nạp ${ rawIssuesData . length } issue` );
}
}
} catch ( err ) {
console . error ( 'Error loading issues:' , err );
}
}
function updateIssueKpis () {
const total = rawIssuesData . length ;
const pending = rawIssuesData . filter ( i => i . status === 'CHO_XU_LY' ). length ;
const processing = rawIssuesData . filter ( i => i . status === 'DANG_XU_LY' ). length ;
const resolved = rawIssuesData . filter ( i => i . status === 'DA_XU_LY' || i . status === 'DONG' ). length ;
const totalEl = document . getElementById ( 'issueKpiTotal' );
const pendingEl = document . getElementById ( 'issueKpiPending' );
const procEl = document . getElementById ( 'issueKpiProcessing' );
const resEl = document . getElementById ( 'issueKpiResolved' );
if ( totalEl ) totalEl . innerText = total ;
if ( pendingEl ) pendingEl . innerText = pending ;
if ( procEl ) procEl . innerText = processing ;
if ( resEl ) resEl . innerText = resolved ;
const badge = document . getElementById ( 'badgeIssueCount' );
if ( badge ) {
badge . innerText = pending ;
badge . classList . toggle ( 'bg-rose-100' , pending > 0 );
badge . classList . toggle ( 'text-rose-700' , pending > 0 );
badge . classList . toggle ( 'bg-slate-100' , pending === 0 );
badge . classList . toggle ( 'text-slate-500' , pending === 0 );
}
}
function renderIssuesTable () {
const tbody = document . getElementById ( 'bodyIssuesList' );
if ( ! tbody ) return ;
const fStatus = document . getElementById ( 'filterIssueStatus' ) ? . value || 'ALL' ;
const fCat = document . getElementById ( 'filterIssueCategory' ) ? . value || 'ALL' ;
const keyword = ( document . getElementById ( 'filterIssueKeyword' ) ? . value || '' ). toLowerCase (). trim ();
const filtered = rawIssuesData . filter ( item => {
if ( fStatus !== 'ALL' && item . status !== fStatus ) return false ;
if ( fCat !== 'ALL' && item . category !== fCat ) return false ;
if ( keyword ) {
const matchId = ( item . id || '' ). toLowerCase (). includes ( keyword );
const matchTitle = ( item . title || '' ). toLowerCase (). includes ( keyword );
const matchDesc = ( item . description || '' ). toLowerCase (). includes ( keyword );
const matchReporter = ( item . reporter || '' ). toLowerCase (). includes ( keyword );
const matchDept = ( item . department || '' ). toLowerCase (). includes ( keyword );
if ( ! matchId && ! matchTitle && ! matchDesc && ! matchReporter && ! matchDept ) return false ;
}
return true ;
});
if ( filtered . length === 0 ) {
tbody . innerHTML = '<tr><td colspan="8" class="text-center py-8 text-slate-400">Không có issue nào phù hợp với bộ lọc.</td></tr>' ;
return ;
}
tbody . innerHTML = '' ;
filtered . forEach ( row => {
// Badges
const catBadge = getCategoryBadge ( row . category );
const priBadge = getPriorityBadge ( row . priority );
const statusBadge = getStatusBadge ( row . status );
const createdStr = formatDateTime ( row . createdAt );
// Attachment
let attachHtml = '<span class="text-slate-300 italic">-</span>' ;
if ( row . attachment ) {
const attachUrl = ` ${ API_BASE }${ row . attachment . url } ` ;
if ( row . attachment . mimetype && row . attachment . mimetype . startsWith ( 'image/' )) {
attachHtml = `
<button onclick="openImageLightbox(' ${ attachUrl } ', ' ${ escapeHtml ( row . title ) } ')" class="group relative inline-block">
<img src=" ${ attachUrl } " class="w-10 h-10 object-cover rounded border border-slate-200 group-hover:border-rose-500 shadow-sm" alt="Thumbnail">
<span class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white text-xs rounded transition-opacity">
<i class="fa-solid fa-magnifying-glass-plus"></i>
</span>
</button>
` ;
} else {
attachHtml = `
<a href=" ${ attachUrl } " target="_blank" download class="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 bg-blue-50 px-2 py-1 rounded border border-blue-200 text-[10px] font-medium" title=" ${ escapeHtml ( row . attachment . originalname ) } ">
<i class="fa-solid fa-file-arrow-down"></i> Tải file
</a>
` ;
}
}
// Handler notes
let notesHtml = '' ;
if ( row . handlerNotes ) {
notesHtml = `
<div class="mt-1.5 p-1.5 bg-emerald-50 rounded border border-emerald-200 text-[11px] text-emerald-800">
<i class="fa-solid fa-reply mr-1 text-emerald-600"></i><b>Xử lý ( ${ escapeHtml ( row . resolvedBy || 'IT' ) } ):</b> ${ escapeHtml ( row . handlerNotes ) }
</div>
` ;
}
tbody . innerHTML += `
<tr class="hover:bg-slate-50 transition-colors">
<td class="py-2.5 px-3">
<div class="font-mono font-bold text-rose-700 text-xs"> ${ row . id } </div>
<div class="text-[10px] text-slate-400 mt-0.5"> ${ createdStr } </div>
</td>
<td class="py-2.5 px-3">
<div class="font-bold text-slate-800 text-xs"> ${ escapeHtml ( row . title ) } </div>
<div class="text-slate-600 text-[11px] mt-1 whitespace-pre-wrap leading-relaxed"> ${ escapeHtml ( row . description ) } </div>
${ notesHtml }
</td>
<td class="py-2.5 px-3">
<div class="space-y-1">
<div> ${ catBadge } </div>
<div class="text-[10px] text-slate-500 font-medium"> ${ getModuleLabel ( row . module ) } </div>
<div> ${ priBadge } </div>
</div>
</td>
<td class="py-2.5 px-3">
<div class="font-medium text-slate-700 truncate max-w-[170px]" title=" ${ escapeHtml ( row . databaseName || row . database ) } ">
${ escapeHtml ( row . databaseName || row . database ) }
</div>
<div class="text-[10px] text-slate-400 mt-0.5"> ${ escapeHtml ( row . period || '-' ) } </div>
</td>
<td class="py-2.5 px-3">
<div class="font-semibold text-slate-800"> ${ escapeHtml ( row . reporter || 'Cán bộ' ) } </div>
<div class="text-[10px] text-slate-500"> ${ escapeHtml ( row . department || '-' ) } </div>
</td>
<td class="py-2.5 px-3 text-center">
${ attachHtml }
</td>
<td class="py-2.5 px-3 text-center">
${ statusBadge }
</td>
<td class="py-2.5 px-3 text-center">
<div class="flex items-center justify-center gap-1">
<button onclick="openUpdateModal(' ${ row . id } ')" class="p-1.5 rounded hover:bg-blue-50 text-blue-600 hover:text-blue-800" title="Cập nhật trạng thái & ghi chú xử lý">
<i class="fa-solid fa-pen-to-square"></i>
</button>
<button onclick="handleDeleteIssue(' ${ row . id } ')" class="p-1.5 rounded hover:bg-rose-50 text-rose-500 hover:text-rose-700" title="Xóa issue">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
</td>
</tr>
` ;
});
}
function getCategoryBadge ( cat ) {
switch ( cat ) {
case 'SAI_LECH_SO_LIEU' :
return '<span class="bg-rose-100 text-rose-800 px-1.5 py-0.5 rounded text-[10px] font-bold">Sai lệch số liệu</span>' ;
case 'LOI_GIAO_DIEN' :
return '<span class="bg-amber-100 text-amber-800 px-1.5 py-0.5 rounded text-[10px] font-bold">Lỗi giao diện</span>' ;
case 'LOI_KET_NOI' :
return '<span class="bg-purple-100 text-purple-800 px-1.5 py-0.5 rounded text-[10px] font-bold">Lỗi kết nối</span>' ;
case 'DE_XUAT_CAI_TIEN' :
return '<span class="bg-sky-100 text-sky-800 px-1.5 py-0.5 rounded text-[10px] font-bold">Đề xuất cải tiến</span>' ;
case 'YEU_CAU_MOI' :
return '<span class="bg-indigo-100 text-indigo-800 px-1.5 py-0.5 rounded text-[10px] font-bold">Yêu cầu mới</span>' ;
default :
return '<span class="bg-slate-100 text-slate-700 px-1.5 py-0.5 rounded text-[10px] font-medium">Khác</span>' ;
}
}
function getPriorityBadge ( pri ) {
switch ( pri ) {
case 'KHAN_CAP' :
return '<span class="bg-rose-600 text-white px-1.5 py-0.2 rounded text-[9px] font-black uppercase">Khẩn cấp</span>' ;
case 'CAO' :
return '<span class="bg-amber-500 text-white px-1.5 py-0.2 rounded text-[9px] font-bold uppercase">Cao</span>' ;
case 'THAP' :
return '<span class="bg-slate-100 text-slate-500 px-1.5 py-0.2 rounded text-[9px]">Thấp</span>' ;
default :
return '<span class="bg-slate-200 text-slate-700 px-1.5 py-0.2 rounded text-[9px] font-medium">Bình thường</span>' ;
}
}
function getStatusBadge ( st ) {
switch ( st ) {
case 'CHO_XU_LY' :
return '<span class="inline-flex items-center gap-1 bg-amber-100 text-amber-800 border border-amber-300 px-2 py-0.5 rounded-full text-[10px] font-bold"><i class="fa-solid fa-clock"></i> Chờ xử lý</span>' ;
case 'DANG_XU_LY' :
return '<span class="inline-flex items-center gap-1 bg-blue-100 text-blue-800 border border-blue-300 px-2 py-0.5 rounded-full text-[10px] font-bold"><i class="fa-solid fa-spinner fa-spin"></i> Đang xử lý</span>' ;
case 'DA_XU_LY' :
return '<span class="inline-flex items-center gap-1 bg-emerald-100 text-emerald-800 border border-emerald-300 px-2 py-0.5 rounded-full text-[10px] font-bold"><i class="fa-solid fa-check"></i> Đã xử lý</span>' ;
case 'DONG' :
return '<span class="inline-flex items-center gap-1 bg-slate-100 text-slate-600 border border-slate-300 px-2 py-0.5 rounded-full text-[10px] font-medium"><i class="fa-solid fa-ban"></i> Đã đóng</span>' ;
default :
return st ;
}
}
function getModuleLabel ( mod ) {
switch ( mod ) {
case 'NGOAI_TRU' : return '1. Ngoại trú' ;
case 'NOI_TRU' : return '2. Nội trú' ;
case 'CHUYEN_VIEN' : return 'Bảng 3. Chuyển viện' ;
case 'THEO_DOI' : return 'Bảng 4. BN Theo dõi' ;
default : return 'Toàn hệ thống' ;
}
}
function formatDateTime ( iso ) {
if ( ! iso ) return '-' ;
const d = new Date ( iso );
if ( isNaN ( d . getTime ())) return iso ;
const day = String ( d . getDate ()). padStart ( 2 , '0' );
const month = String ( d . getMonth () + 1 ). padStart ( 2 , '0' );
const year = d . getFullYear ();
const hours = String ( d . getHours ()). padStart ( 2 , '0' );
const minutes = String ( d . getMinutes ()). padStart ( 2 , '0' );
return ` ${ day } / ${ month } / ${ year } ${ hours } : ${ minutes } ` ;
}
function escapeHtml ( str ) {
if ( ! str ) return '' ;
return String ( str )
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /"/g , '"' )
. replace ( /'/g , ''' );
}
function openUpdateModal ( issueId ) {
const item = rawIssuesData . find ( i => i . id === issueId );
if ( ! item ) return ;
editingIssueId = issueId ;
document . getElementById ( 'updateModalIssueId' ). innerText = issueId ;
document . getElementById ( 'updateStatus' ). value = item . status || 'CHO_XU_LY' ;
document . getElementById ( 'updateResolvedBy' ). value = item . resolvedBy || '' ;
document . getElementById ( 'updateHandlerNotes' ). value = item . handlerNotes || '' ;
const modal = document . getElementById ( 'modalUpdateIssue' );
modal . classList . remove ( 'hidden' );
}
function closeUpdateModal () {
const modal = document . getElementById ( 'modalUpdateIssue' );
modal . classList . add ( 'hidden' );
editingIssueId = null ;
}
async function handleUpdateIssue ( e ) {
e . preventDefault ();
if ( ! editingIssueId ) return ;
const btn = document . getElementById ( 'btnSaveUpdateIssue' );
btn . disabled = true ;
btn . innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> <span>Đang lưu...</span>' ;
try {
const status = document . getElementById ( 'updateStatus' ). value ;
const resolvedBy = document . getElementById ( 'updateResolvedBy' ). value . trim ();
const handlerNotes = document . getElementById ( 'updateHandlerNotes' ). value . trim ();
const res = await fetch ( ` ${ API_BASE } /api/issues/ ${ editingIssueId } ` , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ status , resolvedBy , handlerNotes }),
});
const result = await res . json ();
if ( result . success ) {
showToast ( `Đã cập nhật issue ${ editingIssueId } ` );
closeUpdateModal ();
await loadIssues ( false );
} else {
throw new Error ( result . message );
}
} catch ( err ) {
alert ( 'Lỗi cập nhật issue: ' + err . message );
} finally {
btn . disabled = false ;
btn . innerHTML = '<i class="fa-solid fa-floppy-disk"></i> <span>Lưu Cập Nhật</span>' ;
}
}
async function handleDeleteIssue ( issueId ) {
if ( ! confirm ( `Bạn có chắc muốn xóa Issue [ ${ issueId } ] này không?` )) return ;
try {
const res = await fetch ( ` ${ API_BASE } /api/issues/ ${ issueId } ` , {
method : 'DELETE' ,
});
const result = await res . json ();
if ( result . success ) {
showToast ( `Đã xóa issue ${ issueId } ` );
await loadIssues ( false );
} else {
throw new Error ( result . message );
}
} catch ( err ) {
alert ( 'Lỗi xóa issue: ' + err . message );
}
}
function openImageLightbox ( url , caption ) {
const modal = document . getElementById ( 'modalImagePreview' );
const img = document . getElementById ( 'lightboxImg' );
const cap = document . getElementById ( 'lightboxCaption' );
img . src = url ;
cap . innerText = caption || 'Hình ảnh sự cố' ;
modal . classList . remove ( 'hidden' );
}
function closeLightbox () {
const modal = document . getElementById ( 'modalImagePreview' );
modal . classList . add ( 'hidden' );
}
function exportIssuesExcel () {
if ( rawIssuesData . length === 0 ) {
alert ( 'Chưa có issue nào để xuất file!' );
return ;
}
let tableHtml = `
<table border="1">
<thead>
<tr style="background-color: #f1f5f9; font-weight: bold;">
<th>Mã Issue</th>
<th>Ngày Báo Cáo</th>
<th>Tiêu Đề</th>
<th>Phân Loại</th>
<th>Phân Hệ</th>
<th>Mức Độ</th>
<th>CSDL</th>
<th>Kỳ Thời Gian</th>
<th>Người Báo</th>
<th>Khoa / Phòng</th>
<th>Nội Dung Chi Tiết</th>
<th>Trạng Thái</th>
<th>Kỹ Thuật Xử Lý</th>
<th>Ghi Chú Giải Pháp</th>
<th>Ngày Giải Quyết</th>
<th>Tệp Đính Kèm</th>
</tr>
</thead>
<tbody>
` ;
rawIssuesData . forEach ( row => {
tableHtml += `
<tr>
<td> ${ escapeHtml ( row . id ) } </td>
<td> ${ formatDateTime ( row . createdAt ) } </td>
<td> ${ escapeHtml ( row . title ) } </td>
<td> ${ escapeHtml ( row . category ) } </td>
<td> ${ escapeHtml ( row . module ) } </td>
<td> ${ escapeHtml ( row . priority ) } </td>
<td> ${ escapeHtml ( row . databaseName || row . database ) } </td>
<td> ${ escapeHtml ( row . period ) } </td>
<td> ${ escapeHtml ( row . reporter ) } </td>
<td> ${ escapeHtml ( row . department ) } </td>
<td> ${ escapeHtml ( row . description ) } </td>
<td> ${ escapeHtml ( row . status ) } </td>
<td> ${ escapeHtml ( row . resolvedBy || '' ) } </td>
<td> ${ escapeHtml ( row . handlerNotes || '' ) } </td>
<td> ${ formatDateTime ( row . resolvedAt ) } </td>
<td> ${ row . attachment ? escapeHtml ( row . attachment . originalname ) : '' } </td>
</tr>
` ;
});
tableHtml += '</tbody></table>' ;
const blob = new Blob ([ '\ufeff' + tableHtml ], { type : 'application/vnd.ms-excel;charset=utf-8' });
const url = URL . createObjectURL ( blob );
const a = document . createElement ( 'a' );
a . href = url ;
a . download = `DanhSach_Issues_GiaoBan_ ${ Date . now () } .xls` ;
document . body . appendChild ( a );
a . click ();
document . body . removeChild ( a );
showToast ( 'Đã xuất file Excel tổng hợp issue thành công!' );
}
2026-10-27 02:04:23 -07:00
// ========================================================
// BIÊN BẢN GIAO BAN (ISS-20261027-002: MẪU A4 & 3 CẤP PHÂN QUYỀN)
// ========================================================
function initBienBan () {
const roleSelect = document . getElementById ( 'selectBienBanRole' );
const btnSync = document . getElementById ( 'btnSync24hData' );
const btnSave = document . getElementById ( 'btnSaveBienBan' );
const btnLock = document . getElementById ( 'btnToggleLockBienBan' );
const btnPrint = document . getElementById ( 'btnPrintBienBan' );
if ( ! roleSelect ) return ;
// Restore saved role
const savedRole = localStorage . getItem ( 'dh_giaoban_bb_role' ) || 'cap2' ;
roleSelect . value = savedRole ;
// Check saved lock state
isBienBanLocked = localStorage . getItem ( 'dh_giaoban_bb_locked' ) === 'true' ;
// Load saved content
loadBienBan ();
// Apply permission
applyBienBanPermissions ();
roleSelect . addEventListener ( 'change' , () => {
localStorage . setItem ( 'dh_giaoban_bb_role' , roleSelect . value );
applyBienBanPermissions ();
});
if ( btnSync ) {
btnSync . addEventListener ( 'click' , sync24hData );
}
if ( btnSave ) {
btnSave . addEventListener ( 'click' , saveBienBan );
}
if ( btnLock ) {
btnLock . addEventListener ( 'click' , toggleLockBienBan );
}
if ( btnPrint ) {
btnPrint . addEventListener ( 'click' , () => window . print ());
}
// Update hospital name when DB changes
const selectDb = document . getElementById ( 'selectDb' );
if ( selectDb ) {
selectDb . addEventListener ( 'change' , () => {
const hospitalName = selectDb . selectedOptions [ 0 ] ? . textContent . split ( '(' )[ 0 ]. trim () || 'BỆNH VIỆN ĐA KHOA' ;
const bbTenBv = document . getElementById ( 'bbTenBenhVien' );
if ( bbTenBv && ( ! bbTenBv . value || bbTenBv . value === 'BỆNH VIỆN ĐA KHOA Ô MÔN' )) {
bbTenBv . value = hospitalName . toUpperCase ();
}
});
}
}
function applyBienBanPermissions () {
const role = document . getElementById ( 'selectBienBanRole' ) ? . value || 'cap2' ;
const badgeLock = document . getElementById ( 'badgeLockStatus' );
const btnSync = document . getElementById ( 'btnSync24hData' );
const btnSave = document . getElementById ( 'btnSaveBienBan' );
const btnLock = document . getElementById ( 'btnToggleLockBienBan' );
// Input fields
const generalInputs = [
'bbTenBenhVien' , 'bbSoBienBan' , 'bbNgayThangNam' , 'bbThoiGian' ,
'bbDiaDiem' , 'bbChuTri' , 'bbThuKy' , 'bbThanhPhan' ,
'bbNoiDungKcb' , 'bbNoiDungChuyenMon' , 'bbNoiDungDuocTrangThietBi' , 'bbNoiDungYkienKhoa'
];
const leadershipInput = 'bbNoiDungKetLuan' ;
if ( badgeLock ) {
if ( isBienBanLocked ) {
badgeLock . className = 'text-xs px-2.5 py-1 rounded-full font-bold bg-rose-100 text-rose-800 border border-rose-300 flex items-center gap-1' ;
badgeLock . innerHTML = '<i class="fa-solid fa-lock text-rose-600"></i> Đã khóa biên bản (Chỉ xem)' ;
if ( btnLock ) btnLock . innerHTML = '<i class="fa-solid fa-lock-open mr-1"></i> Mở khóa biên bản' ;
} else {
badgeLock . className = 'text-xs px-2.5 py-1 rounded-full font-bold bg-emerald-100 text-emerald-800 border border-emerald-300 flex items-center gap-1' ;
badgeLock . innerHTML = '<i class="fa-solid fa-lock-open text-emerald-600"></i> Đang mở soạn thảo' ;
if ( btnLock ) btnLock . innerHTML = '<i class="fa-solid fa-lock mr-1"></i> Khóa biên bản' ;
}
}
// Cap 1: Chỉ xem (ReadOnly hết)
if ( role === 'cap1' ) {
generalInputs . forEach ( id => {
const el = document . getElementById ( id );
if ( el ) el . readOnly = true ;
});
const elLead = document . getElementById ( leadershipInput );
if ( elLead ) elLead . readOnly = true ;
if ( btnSync ) btnSync . classList . add ( 'hidden' );
if ( btnSave ) btnSave . classList . add ( 'hidden' );
if ( btnLock ) btnLock . classList . add ( 'hidden' );
return ;
}
// If locked, nobody can edit except cap3 who can toggle unlock
if ( isBienBanLocked ) {
generalInputs . forEach ( id => {
const el = document . getElementById ( id );
if ( el ) el . readOnly = true ;
});
const elLead = document . getElementById ( leadershipInput );
if ( elLead ) elLead . readOnly = true ;
if ( btnSync ) btnSync . classList . add ( 'hidden' );
if ( btnSave ) btnSave . classList . add ( 'hidden' );
if ( btnLock ) {
btnLock . classList . toggle ( 'hidden' , role !== 'cap3' );
}
return ;
}
// Unlocked state:
if ( btnSync ) btnSync . classList . remove ( 'hidden' );
if ( btnSave ) btnSave . classList . remove ( 'hidden' );
if ( role === 'cap2' ) {
// Thư ký: có thể sửa mục chung, KHÔNG thể sửa mục V (Kết luận BGĐ), KHÔNG được khóa
generalInputs . forEach ( id => {
const el = document . getElementById ( id );
if ( el ) el . readOnly = false ;
});
const elLead = document . getElementById ( leadershipInput );
if ( elLead ) {
elLead . readOnly = true ;
elLead . title = 'Chỉ Ban Giám Đốc (Cấp 3) mới có quyền nhập kết luận chỉ đạo.' ;
}
if ( btnLock ) btnLock . classList . add ( 'hidden' );
} else if ( role === 'cap3' ) {
// BGĐ: sửa được tất cả và có nút khóa
generalInputs . forEach ( id => {
const el = document . getElementById ( id );
if ( el ) el . readOnly = false ;
});
const elLead = document . getElementById ( leadershipInput );
if ( elLead ) {
elLead . readOnly = false ;
elLead . removeAttribute ( 'title' );
}
if ( btnLock ) btnLock . classList . remove ( 'hidden' );
}
}
function sync24hData () {
const dFrom = document . getElementById ( 'dateFrom' ) ? . value || '' ;
const dTo = document . getElementById ( 'dateTo' ) ? . value || '' ;
const selectDb = document . getElementById ( 'selectDb' );
const hospitalName = selectDb ? . selectedOptions [ 0 ] ? . textContent . split ( '(' )[ 0 ]. trim () || 'BỆNH VIỆN' ;
// Fill Hospital Name
const bbTenBv = document . getElementById ( 'bbTenBenhVien' );
if ( bbTenBv ) bbTenBv . value = hospitalName . toUpperCase ();
// Fill Date
const bbNgay = document . getElementById ( 'bbNgayThangNam' );
if ( bbNgay ) {
const today = new Date ();
bbNgay . value = `Ngày ${ today . getDate () } tháng ${ today . getMonth () + 1 } năm ${ today . getFullYear () } ` ;
}
let textKcb = `TỔNG HỢP HOẠT ĐỘNG KHÁM CHỮA BỆNH (Kỳ báo cáo: ${ dFrom } đến ${ dTo } )\n\n` ;
// 1. Ngoại trú
if ( rawNgoaiTruData && rawNgoaiTruData . current ) {
const cur = rawNgoaiTruData . current ;
let totalKham = 0 , totalBhyt = 0 , totalThuPhi = 0 , totalCv = 0 ;
const khoaList = cur . khoa || [];
khoaList . forEach ( k => {
totalKham += parseInt ( k . tong_kham || 0 , 10 );
totalBhyt += parseInt ( k . bhyt || 0 , 10 );
totalThuPhi += parseInt ( k . thuphi || 0 , 10 );
totalCv += parseInt ( k . chuyenvien || 0 , 10 );
});
textKcb += `1. Khám Bệnh Ngoại Trú:\n` ;
textKcb += `- Tổng lượt khám: ${ fmt ( totalKham ) } lượt (BHYT: ${ fmt ( totalBhyt ) } lượt, Thu phí: ${ fmt ( totalThuPhi ) } lượt, Chuyển viện: ${ fmt ( totalCv ) } lượt).\n` ;
const topKhoa = khoaList . slice ( 0 , 3 ). map ( k => ` ${ k . tendv } ( ${ fmt ( k . tong_kham ) } )` ). join ( ', ' );
if ( topKhoa ) {
textKcb += `- Các phòng/khoa khám đông nhất: ${ topKhoa } .\n` ;
}
if ( cur . cls && cur . cls . length > 0 ) {
let xn = 0 , cdha = 0 ;
cur . cls . forEach ( c => {
if ( c . kho === 'XN' ) xn += parseInt ( c . so_luot || 0 , 10 );
else cdha += parseInt ( c . so_luot || 0 , 10 );
});
textKcb += `- Cận lâm sàng: Xét nghiệm ${ fmt ( xn ) } lượt; Chẩn đoán hình ảnh & TDCN: ${ fmt ( cdha ) } lượt.\n` ;
}
textKcb += `\n` ;
}
// 2. Nội trú
if ( rawNoiTruData && rawNoiTruData . current ) {
const cur = rawNoiTruData . current ;
let cu = 0 , moi = 0 , on = 0 , nang = 0 , tron = 0 , cv = 0 , tv = 0 , hiencon = 0 , td = 0 , cs1 = 0 ;
( cur . khoa || []). forEach ( k => {
cu += parseInt ( k . benh_cu || 0 , 10 );
moi += parseInt ( k . benh_moi || 0 , 10 );
on += parseInt ( k . on_xin_ve || 0 , 10 );
nang += parseInt ( k . nang_xin_ve || 0 , 10 );
tron += parseInt ( k . tron_vien || 0 , 10 );
cv += parseInt ( k . chuyen_vien || 0 , 10 );
tv += parseInt ( k . tu_vong || 0 , 10 );
hiencon += parseInt ( k . benh_hien_con || 0 , 10 );
td += parseInt ( k . benh_theo_doi || 0 , 10 );
cs1 += parseInt ( k . chamsoc_cap1 || 0 , 10 );
});
const xuatVien = on + nang + tron + cv + tv ;
textKcb += `2. Điều Trị Nội Trú:\n` ;
textKcb += `- Bệnh nhân cũ đầu kỳ: ${ fmt ( cu ) } người.\n` ;
textKcb += `- Bệnh nhân mới vào viện trong kỳ: ${ fmt ( moi ) } người.\n` ;
textKcb += `- Tổng xuất viện: ${ fmt ( xuatVien ) } người (Trong đó: Ổn cho về: ${ fmt ( on ) } , Xin về nặng: ${ fmt ( nang ) } , Trốn viện: ${ fmt ( tron ) } , Chuyển tuyến: ${ fmt ( cv ) } , Tử vong: ${ fmt ( tv ) } ).\n` ;
textKcb += `- Hiện còn đang điều trị tại các khoa: ${ fmt ( hiencon ) } người.\n` ;
textKcb += `- Bệnh nhân nặng cần theo dõi đặc biệt: ${ fmt ( td ) } ca; Chăm sóc Cấp I: ${ fmt ( cs1 ) } ca.\n\n` ;
}
// 3. Chuyển viện
if ( rawBang3Data && rawBang3Data . length > 0 ) {
textKcb += `3. Bệnh Nhân Chuyển Tuyến / Chuyển Viện:\n` ;
textKcb += `- Tổng số: ${ rawBang3Data . length } bệnh nhân được làm thủ tục chuyển viện an toàn theo đúng quy trình chuyên môn (Chi tiết đính kèm Bảng 3).\n` ;
}
const bbKcbEl = document . getElementById ( 'bbNoiDungKcb' );
if ( bbKcbEl ) {
bbKcbEl . value = textKcb . trim ();
}
showToast ( 'Đã đồng bộ số liệu 24h vào biên bản thành công!' );
}
function saveBienBan () {
const fields = [
'bbTenBenhVien' , 'bbSoBienBan' , 'bbNgayThangNam' , 'bbThoiGian' ,
'bbDiaDiem' , 'bbChuTri' , 'bbThuKy' , 'bbThanhPhan' ,
'bbNoiDungKcb' , 'bbNoiDungChuyenMon' , 'bbNoiDungDuocTrangThietBi' , 'bbNoiDungYkienKhoa' , 'bbNoiDungKetLuan'
];
const data = {};
fields . forEach ( id => {
const el = document . getElementById ( id );
if ( el ) data [ id ] = el . value ;
});
const selectDb = document . getElementById ( 'selectDb' ) ? . value || 'default' ;
localStorage . setItem ( `dh_bienban_data_ ${ selectDb } ` , JSON . stringify ( data ));
// Sync sign names
const signThuKy = document . getElementById ( 'bbSignThuKy' );
const bbThuKy = document . getElementById ( 'bbThuKy' );
if ( signThuKy && bbThuKy ) signThuKy . innerText = bbThuKy . value || 'BS. Trực tổng hợp' ;
const signChuTri = document . getElementById ( 'bbSignChuTri' );
const bbChuTri = document . getElementById ( 'bbChuTri' );
if ( signChuTri && bbChuTri ) signChuTri . innerText = bbChuTri . value || 'BS.CKII - Ban Giám Đốc' ;
showToast ( 'Đã lưu biên bản giao ban thành công!' );
}
function loadBienBan () {
const selectDb = document . getElementById ( 'selectDb' ) ? . value || 'default' ;
const raw = localStorage . getItem ( `dh_bienban_data_ ${ selectDb } ` );
if ( ! raw ) return ;
try {
const data = JSON . parse ( raw );
Object . keys ( data ). forEach ( id => {
const el = document . getElementById ( id );
if ( el ) el . value = data [ id ];
});
const signThuKy = document . getElementById ( 'bbSignThuKy' );
const bbThuKy = document . getElementById ( 'bbThuKy' );
if ( signThuKy && bbThuKy && bbThuKy . value ) signThuKy . innerText = bbThuKy . value ;
const signChuTri = document . getElementById ( 'bbSignChuTri' );
const bbChuTri = document . getElementById ( 'bbChuTri' );
if ( signChuTri && bbChuTri && bbChuTri . value ) signChuTri . innerText = bbChuTri . value ;
} catch ( e ) {
console . warn ( 'Lỗi đọc biên bản lưu trữ:' , e );
}
}
function toggleLockBienBan () {
const role = document . getElementById ( 'selectBienBanRole' ) ? . value ;
if ( role !== 'cap3' ) {
alert ( 'Chỉ Ban Giám Đốc / Chủ trì giao ban (Cấp 3) mới có thẩm quyền Khóa hoặc Mở khóa biên bản!' );
return ;
}
isBienBanLocked = ! isBienBanLocked ;
localStorage . setItem ( 'dh_giaoban_bb_locked' , isBienBanLocked ? 'true' : 'false' );
applyBienBanPermissions ();
if ( isBienBanLocked ) {
saveBienBan ();
showToast ( 'Biên bản đã được KHÓA và chuyển sang chế độ CHỈ XEM.' );
} else {
showToast ( 'Biên bản đã được MỞ KHÓA để tiếp tục hiệu chỉnh.' );
}
}