feat: Tích hợp Bản quyền License AES-256 (OTH.Adapter), Biên bản giao ban A4, Biểu đồ tròn và Tài liệu triển khai demo
This commit is contained in:
+767
-2
@@ -3,6 +3,8 @@ const API_BASE = window.location.pathname.startsWith('/web-giao-ban-go') ? '/web
|
||||
let chartNgoaiTruInstance = null;
|
||||
let chartNoiTruInstance = null;
|
||||
let currentChartTypeNgoaiTru = 'khoa';
|
||||
let currentChartTypeNoiTru = 'noitru-col';
|
||||
let isBienBanLocked = false;
|
||||
|
||||
let rawNgoaiTruData = null;
|
||||
let rawNoiTruData = null;
|
||||
@@ -17,6 +19,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
initTabs();
|
||||
initEvents();
|
||||
initIssueFeatures();
|
||||
initBienBan();
|
||||
await checkLicenseStatus();
|
||||
await loadDatabases();
|
||||
loadData();
|
||||
loadIssues(false);
|
||||
@@ -81,9 +85,38 @@ function initPresets() {
|
||||
const quickPreset = document.getElementById('quickPreset');
|
||||
const dateFrom = document.getElementById('dateFrom');
|
||||
const dateTo = document.getElementById('dateTo');
|
||||
const lockStatusFrom = document.getElementById('lockStatusFrom');
|
||||
const lockStatusTo = document.getElementById('lockStatusTo');
|
||||
|
||||
const quickComparePreset = document.getElementById('quickComparePreset');
|
||||
const compareFrom = document.getElementById('compareFrom');
|
||||
const compareTo = document.getElementById('compareTo');
|
||||
const chkHideZeroRows = document.getElementById('chkHideZeroRows');
|
||||
|
||||
function updateLockState() {
|
||||
const isCustom = quickPreset.value === 'custom';
|
||||
dateFrom.readOnly = !isCustom;
|
||||
dateTo.readOnly = !isCustom;
|
||||
if (isCustom) {
|
||||
dateFrom.classList.remove('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
dateFrom.classList.add('bg-white', 'text-slate-800');
|
||||
dateTo.classList.remove('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
dateTo.classList.add('bg-white', 'text-slate-800');
|
||||
if (lockStatusFrom) lockStatusFrom.innerHTML = '<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';
|
||||
}
|
||||
}
|
||||
|
||||
quickPreset.addEventListener('change', () => {
|
||||
const val = quickPreset.value;
|
||||
updateLockState();
|
||||
let f = '', t = '';
|
||||
|
||||
switch (val) {
|
||||
@@ -123,13 +156,94 @@ function initPresets() {
|
||||
f = '2026-01-01';
|
||||
t = '2026-12-31';
|
||||
break;
|
||||
case 'custom':
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
dateFrom.value = f;
|
||||
dateTo.value = t;
|
||||
|
||||
// Auto-update compare preset if relative
|
||||
if (quickComparePreset && quickComparePreset.value !== 'custom') {
|
||||
updateComparePresetDates();
|
||||
}
|
||||
loadData();
|
||||
});
|
||||
|
||||
function updateComparePresetDates() {
|
||||
if (!quickComparePreset || !compareFrom || !compareTo) return;
|
||||
const val = quickComparePreset.value;
|
||||
const isCustom = val === 'custom';
|
||||
compareFrom.readOnly = !isCustom;
|
||||
compareTo.readOnly = !isCustom;
|
||||
|
||||
if (isCustom) {
|
||||
compareFrom.classList.remove('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
compareFrom.classList.add('bg-white', 'text-slate-800');
|
||||
compareTo.classList.remove('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
compareTo.classList.add('bg-white', 'text-slate-800');
|
||||
return;
|
||||
}
|
||||
|
||||
compareFrom.classList.add('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
compareFrom.classList.remove('bg-white', 'text-slate-800');
|
||||
compareTo.classList.add('bg-slate-100', 'cursor-not-allowed', 'text-slate-500');
|
||||
compareTo.classList.remove('bg-white', 'text-slate-800');
|
||||
|
||||
if (val === 'month-01-2026') {
|
||||
compareFrom.value = '2026-01-01';
|
||||
compareTo.value = '2026-01-31';
|
||||
} else if (val === 'month-02-2026') {
|
||||
compareFrom.value = '2026-02-01';
|
||||
compareTo.value = '2026-02-28';
|
||||
} else if (val === 'prev-month') {
|
||||
try {
|
||||
const d1 = new Date(dateFrom.value);
|
||||
d1.setMonth(d1.getMonth() - 1);
|
||||
const d2 = new Date(dateTo.value);
|
||||
d2.setMonth(d2.getMonth() - 1);
|
||||
compareFrom.value = d1.toISOString().slice(0, 10);
|
||||
compareTo.value = d2.toISOString().slice(0, 10);
|
||||
} catch (e) {}
|
||||
} else if (val === 'prev-year') {
|
||||
try {
|
||||
const d1 = new Date(dateFrom.value);
|
||||
d1.setFullYear(d1.getFullYear() - 1);
|
||||
const d2 = new Date(dateTo.value);
|
||||
d2.setFullYear(d2.getFullYear() - 1);
|
||||
compareFrom.value = d1.toISOString().slice(0, 10);
|
||||
compareTo.value = d2.toISOString().slice(0, 10);
|
||||
} catch (e) {}
|
||||
} else if (val === 'prev-period') {
|
||||
try {
|
||||
const d1 = new Date(dateFrom.value);
|
||||
const d2 = new Date(dateTo.value);
|
||||
const diff = d2.getTime() - d1.getTime();
|
||||
const prevEnd = new Date(d1.getTime() - 24 * 3600 * 1000);
|
||||
const prevStart = new Date(prevEnd.getTime() - diff);
|
||||
compareFrom.value = prevStart.toISOString().slice(0, 10);
|
||||
compareTo.value = prevEnd.toISOString().slice(0, 10);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (quickComparePreset) {
|
||||
quickComparePreset.addEventListener('change', () => {
|
||||
updateComparePresetDates();
|
||||
loadData();
|
||||
});
|
||||
}
|
||||
|
||||
if (chkHideZeroRows) {
|
||||
chkHideZeroRows.addEventListener('change', () => {
|
||||
renderNgoaiTru();
|
||||
renderNoiTru();
|
||||
});
|
||||
}
|
||||
|
||||
updateLockState();
|
||||
updateComparePresetDates();
|
||||
}
|
||||
|
||||
// Tab Switching
|
||||
@@ -203,6 +317,28 @@ function initEvents() {
|
||||
});
|
||||
});
|
||||
|
||||
// Chart Type toggle for Inpatient
|
||||
document.querySelectorAll('.chart-nt-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.chart-nt-btn').forEach(b => {
|
||||
b.classList.remove('active', 'bg-blue-100', 'text-blue-700', 'font-semibold');
|
||||
b.classList.add('bg-slate-100', 'text-slate-600', 'font-medium');
|
||||
});
|
||||
btn.classList.add('active', 'bg-blue-100', 'text-blue-700', 'font-semibold');
|
||||
btn.classList.remove('bg-slate-100', 'text-slate-600', 'font-medium');
|
||||
currentChartTypeNoiTru = btn.getAttribute('data-ntchart');
|
||||
renderChartNoiTru();
|
||||
});
|
||||
});
|
||||
|
||||
// License Modal close button
|
||||
const btnCloseLicense = document.getElementById('btnCloseLicenseModal');
|
||||
if (btnCloseLicense) {
|
||||
btnCloseLicense.addEventListener('click', () => {
|
||||
document.getElementById('modalLicenseBlock')?.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// Filters for Table 3 & 4
|
||||
document.getElementById('filterKhoaCv').addEventListener('change', (e) => {
|
||||
renderBang3(e.target.value);
|
||||
@@ -341,6 +477,12 @@ async function loadData() {
|
||||
if (body) body.innerHTML = `<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>`;
|
||||
}
|
||||
|
||||
// Kiểm tra chặn bản quyền từ backend
|
||||
const licBlocked = [resNt, resNoiTru, resB3, resB4].find(r => r.status === 'fulfilled' && r.value && r.value.license_valid === false);
|
||||
if (licBlocked) {
|
||||
showLicenseBlockModal(licBlocked.value.message);
|
||||
}
|
||||
|
||||
if (hasSuccess) {
|
||||
const selName = document.getElementById('selectDb').selectedOptions[0]?.textContent.split('(')[0].trim() || database;
|
||||
showToast(`Đã nạp báo cáo CSDL: ${selName}`);
|
||||
@@ -357,6 +499,54 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
// Hiển thị Modal thông báo yêu cầu kích hoạt bản quyền
|
||||
function showLicenseBlockModal(msg) {
|
||||
const modal = document.getElementById('modalLicenseBlock');
|
||||
const msgEl = document.getElementById('modalLicenseBlockMsg');
|
||||
if (msgEl && msg) {
|
||||
msgEl.innerHTML = `<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);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate Khoa Dropdown
|
||||
function populateKhoaDropdown(selectId, dataList) {
|
||||
const sel = document.getElementById(selectId);
|
||||
@@ -420,11 +610,16 @@ function renderNgoaiTru() {
|
||||
if (!cur.khoa || cur.khoa.length === 0) {
|
||||
tbody.innerHTML = '<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 {
|
||||
const hideZero = document.getElementById('chkHideZeroRows')?.checked;
|
||||
cur.khoa.forEach(row => {
|
||||
const k1 = parseInt(row.tong_kham || 0, 10);
|
||||
const k2 = parseInt(compMap[row.madv]?.tong_kham || 0, 10);
|
||||
if (hideZero) {
|
||||
if (isCompare && k1 === 0 && k2 === 0) return;
|
||||
if (!isCompare && k1 === 0) return;
|
||||
}
|
||||
let compCols = '';
|
||||
if (isCompare) {
|
||||
const k2 = parseInt(compMap[row.madv]?.tong_kham || 0, 10);
|
||||
const diff = k1 - k2;
|
||||
const pct = k2 > 0 ? ((diff / k2) * 100).toFixed(1) + '%' : (k1 > 0 ? '+100%' : '0%');
|
||||
const diffColor = diff > 0 ? 'text-emerald-600 font-bold' : (diff < 0 ? 'text-rose-600 font-bold' : 'text-slate-500');
|
||||
@@ -532,6 +727,106 @@ function renderChartNgoaiTru() {
|
||||
const khoaList = Array.isArray(cur.khoa) ? cur.khoa : [];
|
||||
const clsList = Array.isArray(cur.cls) ? cur.cls : [];
|
||||
|
||||
// 1. Biểu đồ tròn % khám theo từng khoa (ISS-20261026-002)
|
||||
if (currentChartTypeNgoaiTru === 'khoa-pie') {
|
||||
const top6 = khoaList.slice(0, 6);
|
||||
const otherSum = khoaList.slice(6).reduce((acc, k) => acc + parseInt(k.tong_kham || 0, 10), 0);
|
||||
const labels = top6.map(k => k.tendv);
|
||||
const data = top6.map(k => parseInt(k.tong_kham || 0, 10));
|
||||
if (otherSum > 0) {
|
||||
labels.push('Các khoa khác');
|
||||
data.push(otherSum);
|
||||
}
|
||||
const colors = [
|
||||
'rgba(37, 99, 235, 0.85)',
|
||||
'rgba(16, 185, 129, 0.85)',
|
||||
'rgba(245, 158, 11, 0.85)',
|
||||
'rgba(236, 72, 153, 0.85)',
|
||||
'rgba(99, 102, 241, 0.85)',
|
||||
'rgba(6, 182, 212, 0.85)',
|
||||
'rgba(148, 163, 184, 0.85)'
|
||||
];
|
||||
|
||||
chartNgoaiTruInstance = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: data,
|
||||
backgroundColor: colors.slice(0, labels.length),
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: { font: { size: 11, family: 'Inter, sans-serif' }, boxWidth: 14 }
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const val = context.raw || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(1) : 0;
|
||||
return ` ${context.label}: ${fmt(val)} lượt (${pct}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Biểu đồ cột nhóm BHYT vs Thu phí (ISS-20261026-002)
|
||||
if (currentChartTypeNgoaiTru === 'bhyt-group') {
|
||||
const topKhoa = khoaList.slice(0, 7);
|
||||
const labels = topKhoa.map(k => k.tendv);
|
||||
const dataBhyt = topKhoa.map(k => parseInt(k.bhyt || 0, 10));
|
||||
const dataThuPhi = topKhoa.map(k => parseInt(k.thuphi || 0, 10));
|
||||
|
||||
chartNgoaiTruInstance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Khám BHYT',
|
||||
data: dataBhyt,
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.85)',
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
label: 'Khám Thu Phí / Viện Phí',
|
||||
data: dataThuPhi,
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.85)',
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: { font: { size: 11, family: 'Inter, sans-serif' } }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, ticks: { font: { size: 10 } } },
|
||||
x: { ticks: { font: { size: 10 }, maxRotation: 25, minRotation: 0 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Cột Khoa hoặc CLS thông thường
|
||||
let labels = [];
|
||||
let data1 = [];
|
||||
let data2 = [];
|
||||
@@ -624,6 +919,7 @@ function renderNoiTru() {
|
||||
if (khoaList.length === 0) {
|
||||
tbody.innerHTML = '<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 {
|
||||
const hideZero = document.getElementById('chkHideZeroRows')?.checked;
|
||||
khoaList.forEach(row => {
|
||||
const cu = parseInt(row.benh_cu || 0, 10);
|
||||
const moi = parseInt(row.benh_moi || 0, 10);
|
||||
@@ -640,6 +936,11 @@ function renderNoiTru() {
|
||||
totalTron += tron; totalCv += cv; totalTv += tv;
|
||||
totalHienCon += hiencon; totalTd += td; totalCs1 += cs1;
|
||||
|
||||
if (hideZero) {
|
||||
const rowSum = cu + moi + on + nang + tron + cv + tv + hiencon + td + cs1;
|
||||
if (rowSum === 0) return;
|
||||
}
|
||||
|
||||
tbody.innerHTML += `
|
||||
<tr class="hover:bg-slate-50 transition-colors">
|
||||
<td class="py-2 px-3 font-mono text-slate-500">${row.madv}</td>
|
||||
@@ -690,7 +991,7 @@ function renderNoiTru() {
|
||||
renderChartNoiTru();
|
||||
}
|
||||
|
||||
// Render Chart Nội Trú
|
||||
// Render Chart Nội Trú (ISS-20261027-001)
|
||||
function renderChartNoiTru() {
|
||||
if (!rawNoiTruData) return;
|
||||
const canvas = document.getElementById('chartNoiTru');
|
||||
@@ -698,6 +999,7 @@ function renderChartNoiTru() {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const cur = rawNoiTruData.current || {};
|
||||
const comp = rawNoiTruData.compare;
|
||||
const subTitle = document.getElementById('subTitleChartNoiTru');
|
||||
|
||||
if (chartNoiTruInstance) {
|
||||
chartNoiTruInstance.destroy();
|
||||
@@ -705,6 +1007,159 @@ function renderChartNoiTru() {
|
||||
}
|
||||
|
||||
const khoaList = Array.isArray(cur.khoa) ? cur.khoa : [];
|
||||
|
||||
// 1. Biểu đồ tròn % 4 tiêu chí toàn viện (Bệnh cũ, Mới, Xuất viện, Hiện còn)
|
||||
if (currentChartTypeNoiTru === 'noitru-pie-tong') {
|
||||
if (subTitle) subTitle.innerText = 'Tỷ lệ cơ cấu 4 tiêu chí luân chuyển bệnh nhân toàn viện';
|
||||
let cu = 0, moi = 0, xuat = 0, hiencon = 0;
|
||||
khoaList.forEach(k => {
|
||||
cu += parseInt(k.benh_cu || 0, 10);
|
||||
moi += parseInt(k.benh_moi || 0, 10);
|
||||
xuat += (
|
||||
parseInt(k.on_xin_ve || 0, 10) +
|
||||
parseInt(k.nang_xin_ve || 0, 10) +
|
||||
parseInt(k.tron_vien || 0, 10) +
|
||||
parseInt(k.chuyen_vien || 0, 10) +
|
||||
parseInt(k.tu_vong || 0, 10)
|
||||
);
|
||||
hiencon += parseInt(k.benh_hien_con || 0, 10);
|
||||
});
|
||||
|
||||
chartNoiTruInstance = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Bệnh cũ đầu kỳ', 'Bệnh mới vào viện', 'Tổng xuất viện', 'Hiện còn điều trị'],
|
||||
datasets: [{
|
||||
data: [cu, moi, xuat, hiencon],
|
||||
backgroundColor: [
|
||||
'rgba(148, 163, 184, 0.85)',
|
||||
'rgba(37, 99, 235, 0.85)',
|
||||
'rgba(16, 185, 129, 0.85)',
|
||||
'rgba(99, 102, 241, 0.85)'
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { font: { size: 11, family: 'Inter, sans-serif' }, boxWidth: 14 } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const val = context.raw || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(1) : 0;
|
||||
return ` ${context.label}: ${fmt(val)} ca (${pct}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Biểu đồ tròn % bệnh hiện còn theo từng khoa
|
||||
if (currentChartTypeNoiTru === 'noitru-pie-khoa') {
|
||||
if (subTitle) subTitle.innerText = '% Phân bố bệnh nhân hiện còn đang điều trị theo từng khoa';
|
||||
const sorted = [...khoaList].sort((a, b) => parseInt(b.benh_hien_con || 0, 10) - parseInt(a.benh_hien_con || 0, 10));
|
||||
const top6 = sorted.slice(0, 6);
|
||||
const otherSum = sorted.slice(6).reduce((acc, k) => acc + parseInt(k.benh_hien_con || 0, 10), 0);
|
||||
const labels = top6.map(k => k.tendv);
|
||||
const data = top6.map(k => parseInt(k.benh_hien_con || 0, 10));
|
||||
if (otherSum > 0) {
|
||||
labels.push('Các khoa khác');
|
||||
data.push(otherSum);
|
||||
}
|
||||
const colors = [
|
||||
'rgba(99, 102, 241, 0.85)',
|
||||
'rgba(37, 99, 235, 0.85)',
|
||||
'rgba(16, 185, 129, 0.85)',
|
||||
'rgba(245, 158, 11, 0.85)',
|
||||
'rgba(236, 72, 153, 0.85)',
|
||||
'rgba(6, 182, 212, 0.85)',
|
||||
'rgba(148, 163, 184, 0.85)'
|
||||
];
|
||||
|
||||
chartNoiTruInstance = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: data,
|
||||
backgroundColor: colors.slice(0, labels.length),
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { font: { size: 11, family: 'Inter, sans-serif' }, boxWidth: 14 } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const val = context.raw || 0;
|
||||
const pct = total > 0 ? ((val / total) * 100).toFixed(1) : 0;
|
||||
return ` ${context.label}: ${fmt(val)} người (${pct}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Biểu đồ cột nhóm 4 tiêu chí theo khoa (Bệnh cũ, Bệnh mới, Xuất viện, Hiện còn)
|
||||
if (currentChartTypeNoiTru === 'noitru-group') {
|
||||
if (subTitle) subTitle.innerText = 'Cột nhóm đối chiếu 4 tiêu chí chi tiết theo từng khoa';
|
||||
const topKhoa = khoaList.slice(0, 6);
|
||||
const labels = topKhoa.map(k => k.tendv);
|
||||
const dataCu = topKhoa.map(k => parseInt(k.benh_cu || 0, 10));
|
||||
const dataMoi = topKhoa.map(k => parseInt(k.benh_moi || 0, 10));
|
||||
const dataXuat = topKhoa.map(k => (
|
||||
parseInt(k.on_xin_ve || 0, 10) +
|
||||
parseInt(k.nang_xin_ve || 0, 10) +
|
||||
parseInt(k.tron_vien || 0, 10) +
|
||||
parseInt(k.chuyen_vien || 0, 10) +
|
||||
parseInt(k.tu_vong || 0, 10)
|
||||
));
|
||||
const dataHienCon = topKhoa.map(k => parseInt(k.benh_hien_con || 0, 10));
|
||||
|
||||
chartNoiTruInstance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{ label: 'Bệnh cũ', data: dataCu, backgroundColor: 'rgba(148, 163, 184, 0.85)', borderRadius: 3 },
|
||||
{ label: 'Bệnh mới', data: dataMoi, backgroundColor: 'rgba(37, 99, 235, 0.85)', borderRadius: 3 },
|
||||
{ label: 'Xuất viện', data: dataXuat, backgroundColor: 'rgba(16, 185, 129, 0.85)', borderRadius: 3 },
|
||||
{ label: 'Hiện còn', data: dataHienCon, backgroundColor: 'rgba(99, 102, 241, 0.85)', borderRadius: 3 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top', labels: { font: { size: 11, family: 'Inter, sans-serif' } } }
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true },
|
||||
x: { ticks: { maxRotation: 25, minRotation: 0, font: { size: 10 } } }
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Mặc định: Cột vào viện & ra viện (noitru-col)
|
||||
if (subTitle) subTitle.innerText = 'Phân bố lưu lượng và cơ cấu bệnh nhân điều trị';
|
||||
const topKhoa = khoaList.slice(0, 8);
|
||||
const labels = topKhoa.map(k => k.tendv);
|
||||
const dataMoi = topKhoa.map(k => parseInt(k.benh_moi || 0, 10));
|
||||
@@ -1542,3 +1997,313 @@ function exportIssuesExcel() {
|
||||
document.body.removeChild(a);
|
||||
showToast('Đã xuất file Excel tổng hợp issue thành công!');
|
||||
}
|
||||
|
||||
// ========================================================
|
||||
// BIÊN BẢN GIAO BAN (ISS-20261027-002: MẪU A4 & 3 CẤP PHÂN QUYỀN)
|
||||
// ========================================================
|
||||
function initBienBan() {
|
||||
const roleSelect = document.getElementById('selectBienBanRole');
|
||||
const btnSync = document.getElementById('btnSync24hData');
|
||||
const btnSave = document.getElementById('btnSaveBienBan');
|
||||
const btnLock = document.getElementById('btnToggleLockBienBan');
|
||||
const btnPrint = document.getElementById('btnPrintBienBan');
|
||||
|
||||
if (!roleSelect) return;
|
||||
|
||||
// Restore saved role
|
||||
const savedRole = localStorage.getItem('dh_giaoban_bb_role') || 'cap2';
|
||||
roleSelect.value = savedRole;
|
||||
|
||||
// Check saved lock state
|
||||
isBienBanLocked = localStorage.getItem('dh_giaoban_bb_locked') === 'true';
|
||||
|
||||
// Load saved content
|
||||
loadBienBan();
|
||||
|
||||
// Apply permission
|
||||
applyBienBanPermissions();
|
||||
|
||||
roleSelect.addEventListener('change', () => {
|
||||
localStorage.setItem('dh_giaoban_bb_role', roleSelect.value);
|
||||
applyBienBanPermissions();
|
||||
});
|
||||
|
||||
if (btnSync) {
|
||||
btnSync.addEventListener('click', sync24hData);
|
||||
}
|
||||
|
||||
if (btnSave) {
|
||||
btnSave.addEventListener('click', saveBienBan);
|
||||
}
|
||||
|
||||
if (btnLock) {
|
||||
btnLock.addEventListener('click', toggleLockBienBan);
|
||||
}
|
||||
|
||||
if (btnPrint) {
|
||||
btnPrint.addEventListener('click', () => window.print());
|
||||
}
|
||||
|
||||
// Update hospital name when DB changes
|
||||
const selectDb = document.getElementById('selectDb');
|
||||
if (selectDb) {
|
||||
selectDb.addEventListener('change', () => {
|
||||
const hospitalName = selectDb.selectedOptions[0]?.textContent.split('(')[0].trim() || 'BỆNH VIỆN ĐA KHOA';
|
||||
const bbTenBv = document.getElementById('bbTenBenhVien');
|
||||
if (bbTenBv && (!bbTenBv.value || bbTenBv.value === 'BỆNH VIỆN ĐA KHOA Ô MÔN')) {
|
||||
bbTenBv.value = hospitalName.toUpperCase();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyBienBanPermissions() {
|
||||
const role = document.getElementById('selectBienBanRole')?.value || 'cap2';
|
||||
const badgeLock = document.getElementById('badgeLockStatus');
|
||||
const btnSync = document.getElementById('btnSync24hData');
|
||||
const btnSave = document.getElementById('btnSaveBienBan');
|
||||
const btnLock = document.getElementById('btnToggleLockBienBan');
|
||||
|
||||
// Input fields
|
||||
const generalInputs = [
|
||||
'bbTenBenhVien', 'bbSoBienBan', 'bbNgayThangNam', 'bbThoiGian',
|
||||
'bbDiaDiem', 'bbChuTri', 'bbThuKy', 'bbThanhPhan',
|
||||
'bbNoiDungKcb', 'bbNoiDungChuyenMon', 'bbNoiDungDuocTrangThietBi', 'bbNoiDungYkienKhoa'
|
||||
];
|
||||
const leadershipInput = 'bbNoiDungKetLuan';
|
||||
|
||||
if (badgeLock) {
|
||||
if (isBienBanLocked) {
|
||||
badgeLock.className = 'text-xs px-2.5 py-1 rounded-full font-bold bg-rose-100 text-rose-800 border border-rose-300 flex items-center gap-1';
|
||||
badgeLock.innerHTML = '<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.');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user