1545 lines
59 KiB
JavaScript
1545 lines
59 KiB
JavaScript
// 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 & 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!');
|
|
}
|