Files
web-giao-ban/public/app.js
T

1545 lines
59 KiB
JavaScript
Raw Permalink Normal View History

// 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';
let rawNgoaiTruData = null;
let rawNoiTruData = null;
let rawBang3Data = [];
let rawBang4Data = [];
let rawIssuesData = [];
let currentIssueFile = null;
let editingIssueId = null;
document.addEventListener('DOMContentLoaded', async () => {
initPresets();
initTabs();
initEvents();
initIssueFeatures();
await loadDatabases();
loadData();
loadIssues(false);
});
// Format Number with Vietnamese Thousand Separator
function fmt(num) {
if (num === null || num === undefined || isNaN(num)) return '0';
return Number(num).toLocaleString('vi-VN');
}
// Show Toast
function showToast(msg) {
const toast = document.getElementById('toast');
const toastMsg = document.getElementById('toastMsg');
if (!toast || !toastMsg) return;
toastMsg.innerText = msg;
toast.classList.remove('translate-y-20', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
setTimeout(() => {
toast.classList.remove('translate-y-0', 'opacity-100');
toast.classList.add('translate-y-20', 'opacity-0');
}, 2500);
}
// Load dynamic databases list
async function loadDatabases() {
const selectDb = document.getElementById('selectDb');
if (!selectDb) return;
try {
const res = await fetch(`${API_BASE}/api/databases`);
const json = await res.json();
if (json.success && Array.isArray(json.data) && json.data.length > 0) {
const savedDb = localStorage.getItem('dh_giaoban_db') || selectDb.value || 'an_omon_full_04052026';
selectDb.innerHTML = '';
json.data.forEach(db => {
const opt = document.createElement('option');
opt.value = db.id;
opt.textContent = db.name;
if (db.id === savedDb) opt.selected = true;
selectDb.appendChild(opt);
});
updateDbBadge();
}
} catch (err) {
console.warn('Không tải được danh sách CSDL từ server:', err);
}
}
// Update Database Badge on Header
function updateDbBadge() {
const selectDb = document.getElementById('selectDb');
const badge = document.getElementById('currentDbBadge');
if (badge && selectDb && selectDb.selectedOptions[0]) {
const text = selectDb.selectedOptions[0].textContent.split('(')[0].trim();
badge.innerHTML = `<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');
quickPreset.addEventListener('change', () => {
const val = quickPreset.value;
let f = '', t = '';
switch (val) {
case 'month-03-2026':
f = '2026-03-01';
t = '2026-03-31';
break;
case 'month-04-2026':
f = '2026-04-01';
t = '2026-04-30';
break;
case 'month-01-2026':
f = '2026-01-01';
t = '2026-01-31';
break;
case 'month-02-2026':
f = '2026-02-01';
t = '2026-02-28';
break;
case 'today':
f = '2026-03-31';
t = '2026-03-31';
break;
case 'yesterday':
f = '2026-03-30';
t = '2026-03-30';
break;
case 'this-month':
f = '2026-03-01';
t = '2026-03-31';
break;
case 'quarter-1':
f = '2026-01-01';
t = '2026-03-31';
break;
case 'year-2026':
f = '2026-01-01';
t = '2026-12-31';
break;
default:
return;
}
dateFrom.value = f;
dateTo.value = t;
loadData();
});
}
// Tab Switching
function initTabs() {
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
tabBtns.forEach(b => {
b.classList.remove('active', 'border-blue-600', 'text-blue-600');
b.classList.add('border-transparent', 'text-slate-500');
});
btn.classList.add('active', 'border-blue-600', 'text-blue-600');
btn.classList.remove('border-transparent', 'text-slate-500');
const target = btn.getAttribute('data-tab');
tabContents.forEach(c => {
c.classList.toggle('hidden', c.id !== target);
});
// Resize charts or load issues
if (target === 'tab-ngoaitru' && chartNgoaiTruInstance) {
chartNgoaiTruInstance.resize();
} else if (target === 'tab-noitru' && chartNoiTruInstance) {
chartNoiTruInstance.resize();
} else if (target === 'tab-issues') {
loadIssues(true);
}
});
});
}
// Events
function initEvents() {
const enableCompare = document.getElementById('enableCompare');
const compareContainer = document.getElementById('compareContainer');
const btnFilter = document.getElementById('btnFilter');
const selectDb = document.getElementById('selectDb');
const btnPrint = document.getElementById('btnPrint');
const btnExport = document.getElementById('btnExport');
enableCompare.addEventListener('change', () => {
compareContainer.classList.toggle('hidden', !enableCompare.checked);
document.querySelectorAll('.th-compare').forEach(el => {
el.classList.toggle('hidden', !enableCompare.checked);
});
loadData();
});
btnFilter.addEventListener('click', () => loadData());
selectDb.addEventListener('change', () => {
updateDbBadge();
loadData();
});
btnPrint.addEventListener('click', () => window.print());
btnExport.addEventListener('click', () => exportExcel());
// Chart Type toggle for Outpatient
document.querySelectorAll('.chart-type-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.chart-type-btn').forEach(b => {
b.classList.remove('active', 'bg-blue-100', 'text-blue-700', 'font-semibold');
b.classList.add('bg-slate-100', 'text-slate-600', 'font-medium');
});
btn.classList.add('active', 'bg-blue-100', 'text-blue-700', 'font-semibold');
btn.classList.remove('bg-slate-100', 'text-slate-600', 'font-medium');
currentChartTypeNgoaiTru = btn.getAttribute('data-chart');
renderChartNgoaiTru();
});
});
// Filters for Table 3 & 4
document.getElementById('filterKhoaCv').addEventListener('change', (e) => {
renderBang3(e.target.value);
});
document.getElementById('filterKhoaTd').addEventListener('change', (e) => {
renderBang4(e.target.value);
});
}
// Get Request Params
function getParams() {
const database = document.getElementById('selectDb').value;
const from = document.getElementById('dateFrom').value + ' 00:00:00';
const to = document.getElementById('dateTo').value + ' 23:59:59';
const isCompare = document.getElementById('enableCompare').checked;
let urlParams = `database=${database}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
if (isCompare) {
const compareFrom = document.getElementById('compareFrom').value + ' 00:00:00';
const compareTo = document.getElementById('compareTo').value + ' 23:59:59';
urlParams += `&compareFrom=${encodeURIComponent(compareFrom)}&compareTo=${encodeURIComponent(compareTo)}`;
}
return { database, from, to, isCompare, urlParams };
}
// Set Loading State
function setLoading(isLoading) {
const btnFilter = document.getElementById('btnFilter');
const selectDb = document.getElementById('selectDb');
const quickPreset = document.getElementById('quickPreset');
if (isLoading) {
if (btnFilter) {
btnFilter.disabled = true;
btnFilter.innerHTML = '<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>`;
}
if (hasSuccess) {
const selName = document.getElementById('selectDb').selectedOptions[0]?.textContent.split('(')[0].trim() || database;
showToast(`Đã nạp báo cáo CSDL: ${selName}`);
}
if (errors.length > 0) {
console.warn('Errors loading some report sections:', errors);
showToast(`Cảnh báo: ${errors.join('; ')}`);
}
} catch (err) {
console.error('Fatal load error:', err);
showToast('Lỗi nghiêm trọng khi tải dữ liệu: ' + err.message);
} finally {
setLoading(false);
}
}
// Populate Khoa Dropdown
function populateKhoaDropdown(selectId, dataList) {
const sel = document.getElementById(selectId);
const currentVal = sel.value;
const depts = Array.from(new Set(dataList.map(item => item.tendv).filter(Boolean)));
sel.innerHTML = '<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 {
cur.khoa.forEach(row => {
const k1 = parseInt(row.tong_kham || 0, 10);
let compCols = '';
if (isCompare) {
const k2 = parseInt(compMap[row.madv]?.tong_kham || 0, 10);
const diff = k1 - k2;
const pct = k2 > 0 ? ((diff / k2) * 100).toFixed(1) + '%' : (k1 > 0 ? '+100%' : '0%');
const diffColor = diff > 0 ? 'text-emerald-600 font-bold' : (diff < 0 ? 'text-rose-600 font-bold' : 'text-slate-500');
compCols = `
<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 : [];
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 {
khoaList.forEach(row => {
const cu = parseInt(row.benh_cu || 0, 10);
const moi = parseInt(row.benh_moi || 0, 10);
const on = parseInt(row.on_xin_ve || 0, 10);
const nang = parseInt(row.nang_xin_ve || 0, 10);
const tron = parseInt(row.tron_vien || 0, 10);
const cv = parseInt(row.chuyen_vien || 0, 10);
const tv = parseInt(row.tu_vong || 0, 10);
const hiencon = parseInt(row.benh_hien_con || 0, 10);
const td = parseInt(row.benh_theo_doi || 0, 10);
const cs1 = parseInt(row.chamsoc_cap1 || 0, 10);
totalCu += cu; totalMoi += moi; totalOn += on; totalNang += nang;
totalTron += tron; totalCv += cv; totalTv += tv;
totalHienCon += hiencon; totalTd += td; totalCs1 += cs1;
tbody.innerHTML += `
<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();
}
// Render Chart Nội Trú
function renderChartNoiTru() {
if (!rawNoiTruData) return;
const canvas = document.getElementById('chartNoiTru');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const cur = rawNoiTruData.current || {};
const comp = rawNoiTruData.compare;
if (chartNoiTruInstance) {
chartNoiTruInstance.destroy();
chartNoiTruInstance = null;
}
const khoaList = Array.isArray(cur.khoa) ? cur.khoa : [];
const topKhoa = khoaList.slice(0, 8);
const labels = topKhoa.map(k => k.tendv);
const dataMoi = topKhoa.map(k => parseInt(k.benh_moi || 0, 10));
const dataRaVien = topKhoa.map(k => (
parseInt(k.on_xin_ve || 0, 10) +
parseInt(k.nang_xin_ve || 0, 10) +
parseInt(k.tron_vien || 0, 10) +
parseInt(k.chuyen_vien || 0, 10) +
parseInt(k.tu_vong || 0, 10)
));
const dataHienCon = topKhoa.map(k => parseInt(k.benh_hien_con || 0, 10));
const datasets = [
{
label: 'Bệnh mới vào viện',
data: dataMoi,
backgroundColor: 'rgba(37, 99, 235, 0.85)',
borderRadius: 4,
},
{
label: 'Tổng xuất viện',
data: dataRaVien,
backgroundColor: 'rgba(16, 185, 129, 0.85)',
borderRadius: 4,
},
{
label: 'Hiện còn đang điều trị',
data: dataHienCon,
backgroundColor: 'rgba(99, 102, 241, 0.85)',
borderRadius: 4,
}
];
if (comp && Array.isArray(comp.khoa)) {
const compMap = {};
comp.khoa.forEach(k => compMap[k.madv] = k);
const dataMoiComp = topKhoa.map(k => parseInt(compMap[k.madv]?.benh_moi || 0, 10));
datasets.push({
label: 'Bệnh mới (Kỳ so sánh)',
data: dataMoiComp,
backgroundColor: 'rgba(234, 88, 12, 0.85)',
borderRadius: 4,
});
}
chartNoiTruInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: { font: { size: 11, family: 'Inter, sans-serif' } },
},
},
scales: {
y: { beginAtZero: true },
x: { ticks: { maxRotation: 25, minRotation: 0 } },
},
},
});
}
// ========================================================
// RENDER BẢNG 3: BỆNH NHÂN CHUYỂN VIỆN
// ========================================================
function renderBang3(filterDept) {
const tbody = document.getElementById('bodyBang3');
tbody.innerHTML = '';
const filtered = filterDept === 'ALL'
? rawBang3Data
: rawBang3Data.filter(item => item.tendv === filterDept);
document.getElementById('countBang3').innerText = `${filtered.length} ca`;
if (filtered.length === 0) {
tbody.innerHTML = `<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 &amp; 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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!');
}