| Server IP : 172.67.189.169 / Your IP : 216.73.216.59 Web Server : Apache/2 System : Linux cat167-197.static.lnwhostname.com 5.10.0-20-amd64 #1 SMP Debian 5.10.158-2 (2022-12-13) x86_64 User : paphayomho ( 1042) PHP Version : 5.6.40 Disable Function : exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname MySQL : ON | cURL : ON | WGET : OFF | Perl : OFF | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : /home/paphayomho/domains/paphayomhospital.go.th/private_html/ |
Upload File : |
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HDC Pre-Check 43 Files | โรงพยาบาลป่าพะยอม</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Sarabun:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root {
--primary: #0d6e4d;
--primary-light: #16a372;
--primary-dark: #094d37;
--accent: #00b4d8;
--accent2: #f59e0b;
--danger: #dc2626;
--warning: #f59e0b;
--info: #0ea5e9;
--success: #16a34a;
--bg: #f0f7f4;
--bg2: #ffffff;
--bg3: #e8f4ef;
--border: #d1e7dd;
--text: #1a2e25;
--text2: #4a6b5a;
--text3: #7a9b8a;
--shadow: 0 2px 8px rgba(13,110,77,0.12);
--shadow-lg: 0 8px 32px rgba(13,110,77,0.15);
--radius: 10px;
--radius-sm: 6px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Sarabun', sans-serif; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.6; }
/* ===== TOPBAR ===== */
.topbar {
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 60%, var(--primary-light) 100%);
color: white; padding: 0 24px;
display: flex; align-items: center; gap: 16px;
height: 58px; box-shadow: 0 2px 12px rgba(0,0,0,0.2); position: sticky; top:0; z-index:100;
}
.topbar-logo { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
.topbar-logo span { color: #7ee8c8; }
.topbar-sub { font-size: 12px; color: rgba(255,255,255,0.7); margin-left: 4px; }
.topbar-spacer { flex: 1; }
.topbar-hcode { background: rgba(255,255,255,0.15); border-radius: 20px; padding: 4px 14px; font-size: 12px; font-weight: 600; font-family: 'IBM Plex Mono', monospace; }
/* ===== NAV TABS ===== */
.navtabs { background: var(--bg2); border-bottom: 2px solid var(--border); display: flex; overflow-x: auto; }
.navtab { padding: 12px 20px; cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text2); white-space: nowrap; border-bottom: 3px solid transparent; transition: all .2s; display: flex; align-items: center; gap: 6px; }
.navtab:hover { color: var(--primary); background: var(--bg3); }
.navtab.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 700; }
.navtab .badge { background: var(--danger); color: white; border-radius: 10px; padding: 1px 6px; font-size: 10px; font-weight: 700; }
.navtab .badge.ok { background: var(--success); }
.navtab .badge.warn { background: var(--warning); }
/* ===== LAYOUT ===== */
.container { max-width: 1400px; margin: 0 auto; padding: 20px 24px; }
.page { display: none; }
.page.active { display: block; }
/* ===== CARDS ===== */
.card { background: var(--bg2); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px; margin-bottom: 16px; border: 1px solid var(--border); }
.card-title { font-size: 16px; font-weight: 700; color: var(--primary-dark); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
.card-title .icon { width: 28px; height: 28px; border-radius: 6px; background: var(--primary); color: white; display: flex; align-items: center; justify-content: center; font-size: 14px; }
/* ===== UPLOAD ZONE ===== */
#dropzone {
border: 3px dashed var(--primary-light); border-radius: var(--radius); padding: 60px 40px;
text-align: center; cursor: pointer; transition: all .3s; background: var(--bg3); position: relative;
}
#dropzone:hover, #dropzone.drag { border-color: var(--primary); background: #d4ede5; }
#dropzone .dz-icon { font-size: 52px; margin-bottom: 12px; }
#dropzone .dz-title { font-size: 20px; font-weight: 700; color: var(--primary-dark); margin-bottom: 6px; }
#dropzone .dz-sub { color: var(--text2); font-size: 14px; }
#fileInput { display: none; }
/* ===== PROGRESS ===== */
.progress-wrap { margin: 20px 0; }
.progress-label { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 13px; font-weight: 600; color: var(--text2); }
.progress-bar { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--primary), var(--accent)); border-radius: 4px; transition: width .3s ease; }
.status-text { text-align: center; color: var(--text2); font-size: 13px; margin-top: 8px; font-style: italic; }
/* ===== KPI GRID ===== */
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 20px; }
.kpi-card { background: var(--bg2); border-radius: var(--radius); padding: 16px; border: 1px solid var(--border); text-align: center; }
.kpi-card.green { border-top: 4px solid var(--success); }
.kpi-card.red { border-top: 4px solid var(--danger); }
.kpi-card.yellow { border-top: 4px solid var(--warning); }
.kpi-card.blue { border-top: 4px solid var(--accent); }
.kpi-card.purple { border-top: 4px solid #8b5cf6; }
.kpi-val { font-size: 28px; font-weight: 800; font-family: 'IBM Plex Mono', monospace; color: var(--primary-dark); line-height: 1; }
.kpi-val.red { color: var(--danger); }
.kpi-val.yellow { color: #b45309; }
.kpi-label { font-size: 12px; color: var(--text2); margin-top: 4px; line-height: 1.3; }
/* ===== SCORE RING ===== */
.score-ring-wrap { display: flex; align-items: center; justify-content: center; gap: 32px; padding: 20px; flex-wrap: wrap; }
.score-ring { position: relative; width: 140px; height: 140px; }
.score-ring canvas { width: 140px !important; height: 140px !important; }
.score-center { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; }
.score-num { font-size: 32px; font-weight: 900; color: var(--primary-dark); font-family: 'IBM Plex Mono', monospace; }
.score-label { font-size: 11px; color: var(--text2); }
/* ===== TABLES ===== */
.tbl-wrap { overflow-x: auto; border-radius: var(--radius-sm); border: 1px solid var(--border); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th { background: var(--primary); color: white; padding: 10px 12px; text-align: left; font-weight: 600; white-space: nowrap; position: sticky; top: 0; z-index: 2; }
tbody tr { border-bottom: 1px solid var(--border); transition: background .15s; }
tbody tr:hover { background: var(--bg3); }
tbody td { padding: 8px 12px; vertical-align: middle; }
tbody tr:nth-child(even) { background: #f9fdfb; }
tbody tr:nth-child(even):hover { background: var(--bg3); }
/* ===== SEVERITY BADGES ===== */
.sev { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; white-space: nowrap; }
.sev-critical { background: #fee2e2; color: #991b1b; }
.sev-high { background: #ffedd5; color: #92400e; }
.sev-medium { background: #fef9c3; color: #713f12; }
.sev-low { background: #dbeafe; color: #1e40af; }
.sev-info { background: #f0fdf4; color: #166534; }
/* ===== BUTTONS ===== */
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 18px; border-radius: var(--radius-sm); font-family: 'Sarabun', sans-serif; font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: all .2s; white-space: nowrap; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-success { background: var(--success); color: white; }
.btn-success:hover { background: #15803d; }
.btn-warning { background: var(--warning); color: white; }
.btn-warning:hover { background: #d97706; }
.btn-danger { background: var(--danger); color: white; }
.btn-info { background: var(--accent); color: white; }
.btn-outline { background: transparent; color: var(--primary); border: 2px solid var(--primary); }
.btn-outline:hover { background: var(--bg3); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-group { display: flex; gap: 8px; flex-wrap: wrap; }
/* ===== FILE STATUS TABLE ===== */
.file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
.file-item { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px 14px; display: flex; align-items: center; gap: 10px; }
.file-item .fi-icon { font-size: 20px; }
.file-item .fi-name { font-family: 'IBM Plex Mono', monospace; font-size: 12px; font-weight: 600; color: var(--primary-dark); }
.file-item .fi-info { font-size: 11px; color: var(--text2); }
.file-item .fi-status { margin-left: auto; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.dot-ok { background: var(--success); }
.dot-err { background: var(--danger); }
.dot-warn { background: var(--warning); }
/* ===== ALERT BOXES ===== */
.alert { padding: 12px 16px; border-radius: var(--radius-sm); margin-bottom: 12px; border-left: 4px solid; display: flex; gap: 10px; align-items: flex-start; }
.alert-info { background: #eff6ff; border-color: var(--info); color: #1e40af; }
.alert-warning { background: #fffbeb; border-color: var(--warning); color: #92400e; }
.alert-danger { background: #fef2f2; border-color: var(--danger); color: #991b1b; }
.alert-success { background: #f0fdf4; border-color: var(--success); color: #166534; }
/* ===== FILTER BAR ===== */
.filter-bar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; align-items: center; }
.filter-bar select, .filter-bar input { padding: 7px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); font-family: 'Sarabun', sans-serif; font-size: 13px; background: white; color: var(--text); outline: none; }
.filter-bar select:focus, .filter-bar input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(13,110,77,0.1); }
.filter-label { font-weight: 600; font-size: 13px; color: var(--text2); }
.count-badge { background: var(--primary); color: white; border-radius: 12px; padding: 2px 10px; font-size: 12px; font-weight: 700; margin-left: auto; }
/* ===== CHARTS GRID ===== */
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media(max-width:900px) { .charts-grid { grid-template-columns: 1fr; } }
.chart-box { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
.chart-box canvas { max-height: 220px; }
/* ===== AUDIT LOG ===== */
.log-entry { display: flex; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); align-items: flex-start; font-size: 12px; }
.log-time { font-family: 'IBM Plex Mono', monospace; color: var(--text3); white-space: nowrap; font-size: 11px; }
.log-action { font-weight: 600; }
.log-detail { color: var(--text2); }
/* ===== MONO ===== */
.mono { font-family: 'IBM Plex Mono', monospace; font-size: 12px; }
/* ===== LOADING OVERLAY ===== */
#loadingOverlay { position: fixed; inset: 0; background: rgba(9,77,55,0.85); display: none; align-items: center; justify-content: center; z-index: 9999; flex-direction: column; gap: 20px; }
#loadingOverlay.show { display: flex; }
.spinner { width: 48px; height: 48px; border: 5px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { color: white; font-size: 16px; font-weight: 600; }
.loading-sub { color: rgba(255,255,255,0.7); font-size: 13px; }
/* ===== MODAL ===== */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 500; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-backdrop.show { display: flex; }
.modal { background: white; border-radius: var(--radius); max-width: 720px; width: 100%; max-height: 80vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); }
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.modal-title { font-size: 16px; font-weight: 700; color: var(--primary-dark); }
.modal-body { padding: 20px; overflow-y: auto; flex: 1; }
.modal-footer { padding: 12px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; }
.modal-close { background: none; border: none; font-size: 20px; cursor: pointer; color: var(--text2); }
/* ===== MAPPING TABLE ===== */
.map-row { display: grid; grid-template-columns: 120px 1fr 120px 120px 80px; gap: 8px; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
.map-header { font-weight: 700; color: var(--primary-dark); background: var(--bg3); padding: 8px 0; }
/* ===== SECTION DIVIDER ===== */
.section-div { display: flex; align-items: center; gap: 12px; margin: 24px 0 16px; }
.section-div span { font-size: 15px; font-weight: 700; color: var(--primary-dark); white-space: nowrap; }
.section-div::before, .section-div::after { content:''; flex:1; height:2px; background: var(--border); }
/* ===== EMPTY STATE ===== */
.empty-state { text-align: center; padding: 60px 20px; color: var(--text2); }
.empty-state .es-icon { font-size: 56px; margin-bottom: 12px; }
.empty-state .es-title { font-size: 18px; font-weight: 700; color: var(--text); margin-bottom: 6px; }
/* ===== TYPEAREA BAR ===== */
.typearea-bars { display: flex; gap: 8px; margin-top: 12px; }
.ta-bar { flex: 1; text-align: center; }
.ta-bar-inner { border-radius: 4px 4px 0 0; min-height: 8px; }
.ta-bar-label { font-size: 11px; font-weight: 700; margin-top: 4px; }
.ta-bar-val { font-size: 13px; font-weight: 800; font-family: 'IBM Plex Mono', monospace; }
/* ===== FIX PREVIEW ===== */
.fix-preview { background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; font-size: 12px; }
.fix-row { display: flex; gap: 8px; align-items: center; padding: 4px 0; }
.fix-from { text-decoration: line-through; color: var(--danger); font-family: 'IBM Plex Mono', monospace; }
.fix-to { color: var(--success); font-weight: 700; font-family: 'IBM Plex Mono', monospace; }
/* ===== MANUAL EDIT ===== */
.edit-grid { display: grid; grid-template-columns: 150px 1fr; gap: 10px 12px; align-items: center; }
.edit-grid label { font-size: 12px; font-weight: 700; color: var(--text2); }
.edit-grid input, .edit-grid select, .edit-grid textarea {
width: 100%; border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 8px 10px; font-family: 'Sarabun', sans-serif; font-size: 13px; color: var(--text);
}
.edit-grid input:focus, .edit-grid select:focus, .edit-grid textarea:focus {
outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(13,110,77,0.1);
}
.edit-help { margin-top: 12px; color: var(--text2); font-size: 12px; line-height: 1.5; }
.action-stack { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
/* ===== RESPONSIVE ===== */
@media(max-width:600px) {
.container { padding: 12px; }
.kpi-grid { grid-template-columns: repeat(2,1fr); }
.topbar { padding: 0 12px; }
.charts-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- Loading Overlay -->
<div id="loadingOverlay">
<div class="spinner"></div>
<div class="loading-text" id="loadingText">กำลังประมวลผล...</div>
<div class="loading-sub" id="loadingSub">กรุณารอสักครู่</div>
</div>
<!-- Modal -->
<div class="modal-backdrop" id="modalBackdrop">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modalTitle">รายละเอียด</div>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer" id="modalFooter">
<button class="btn btn-outline btn-sm" onclick="closeModal()">ปิด</button>
</div>
</div>
</div>
<!-- Top Bar -->
<div class="topbar">
<div class="topbar-logo">HDC Pre<span>-</span>Check</div>
<div class="topbar-sub">ตรวจสอบ 43 แฟ้มก่อน Upload</div>
<div class="topbar-spacer"></div>
<div class="topbar-hcode" id="topHcode">HCODE: —</div>
</div>
<!-- Nav Tabs -->
<div class="navtabs" id="navtabs">
<div class="navtab active" onclick="showPage('upload')" id="tab-upload">📁 Upload & Summary</div>
<div class="navtab" onclick="showPage('dashboard')" id="tab-dashboard">📊 Dashboard</div>
<div class="navtab" onclick="showPage('errors')" id="tab-errors">⚠️ Error Table <span class="badge" id="errBadge">—</span></div>
<div class="navtab" onclick="showPage('person')" id="tab-person">👤 PERSON</div>
<div class="navtab" onclick="showPage('chronic')" id="tab-chronic">🏥 CHRONIC/FU</div>
<div class="navtab" onclick="showPage('lab')" id="tab-lab">🧪 LABFU</div>
<div class="navtab" onclick="showPage('mapping')" id="tab-mapping">🔗 Lab Mapping</div>
<div class="navtab" onclick="showPage('audit')" id="tab-audit">📋 Audit Log</div>
<div class="navtab" onclick="showPage('export')" id="tab-export">💾 Export</div>
</div>
<!-- ===== PAGES ===== -->
<div class="container">
<!-- PAGE: UPLOAD -->
<div class="page active" id="page-upload">
<div class="card">
<div class="card-title"><div class="icon">📁</div> อัปโหลดไฟล์ 43 แฟ้ม</div>
<div id="dropzone" onclick="document.getElementById('fileInput').click()">
<div class="dz-icon">🗜️</div>
<div class="dz-title">วาง ZIP ไฟล์ 43 แฟ้มที่นี่</div>
<div class="dz-sub">หรือคลิกเพื่อเลือกไฟล์ • รองรับ UTF-8 และ TIS-620/CP874</div>
<div class="dz-sub" style="margin-top:8px;font-size:12px;color:var(--text3)">ไฟล์ทั้งหมดประมวลผลใน Browser ไม่ส่งข้อมูลออกภายนอก</div>
</div>
<input type="file" id="fileInput" accept=".zip" onchange="handleFileSelect(event)">
<div class="progress-wrap" id="progressWrap" style="display:none">
<div class="progress-label"><span id="progressLabel">อ่านไฟล์...</span><span id="progressPct">0%</span></div>
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
<div class="status-text" id="statusText"></div>
</div>
</div>
<div id="fileSummarySection" style="display:none">
<div class="card">
<div class="card-title"><div class="icon">📄</div> สรุปไฟล์ในชุดข้อมูล</div>
<div class="kpi-grid" id="fileSummaryKpi"></div>
<div class="file-grid" id="fileGrid"></div>
</div>
<div class="btn-group" style="margin-top:8px">
<button class="btn btn-primary" onclick="runAllChecks()">🔍 ตรวจสอบทั้งหมด</button>
<button class="btn btn-outline" onclick="resetApp()">🔄 โหลดไฟล์ใหม่</button>
</div>
</div>
</div>
<!-- PAGE: DASHBOARD -->
<div class="page" id="page-dashboard">
<div id="dashEmpty" class="empty-state">
<div class="es-icon">📊</div>
<div class="es-title">ยังไม่มีข้อมูล</div>
<div>กรุณาอัปโหลดและตรวจสอบไฟล์ก่อน</div>
</div>
<div id="dashContent" style="display:none">
<div class="score-ring-wrap card">
<div>
<div class="score-ring"><canvas id="scoreChart"></canvas>
<div class="score-center"><div class="score-num" id="scoreNum">—</div><div class="score-label">คะแนน HDC</div></div></div>
</div>
<div style="flex:1;min-width:200px">
<div class="kpi-grid" id="dashKpi"></div>
</div>
</div>
<div class="charts-grid">
<div class="chart-box">
<div style="font-weight:700;color:var(--primary-dark);margin-bottom:8px">Error แยกตาม Severity</div>
<canvas id="sevChart"></canvas>
</div>
<div class="chart-box">
<div style="font-weight:700;color:var(--primary-dark);margin-bottom:8px">Error แยกตาม Module</div>
<canvas id="moduleChart"></canvas>
</div>
<div class="chart-box">
<div style="font-weight:700;color:var(--primary-dark);margin-bottom:8px">TYPEAREA Distribution</div>
<canvas id="typeareaChart"></canvas>
</div>
<div class="chart-box">
<div style="font-weight:700;color:var(--primary-dark);margin-bottom:8px">Top 10 Error ที่พบบ่อย</div>
<canvas id="top10Chart"></canvas>
</div>
</div>
<div class="card" id="dashAlerts"></div>
</div>
</div>
<!-- PAGE: ERRORS -->
<div class="page" id="page-errors">
<div class="card">
<div class="card-title"><div class="icon">⚠️</div> ตารางแสดง Error ทั้งหมด</div>
<div class="filter-bar">
<span class="filter-label">กรอง:</span>
<select id="filterSev" onchange="renderErrorTable()">
<option value="">ทุก Severity</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
<select id="filterFile" onchange="renderErrorTable()">
<option value="">ทุกไฟล์</option>
</select>
<select id="filterModule" onchange="renderErrorTable()">
<option value="">ทุก Module</option>
<option value="PERSON">PERSON</option>
<option value="SERVICE">SERVICE</option>
<option value="CHRONIC">CHRONIC</option>
<option value="CHRONICFU">CHRONICFU</option>
<option value="LABFU">LABFU</option>
<option value="DIAGNOSIS_OPD">DIAGNOSIS_OPD</option>
<option value="PROCEDURE_OPD">PROCEDURE_OPD</option>
</select>
<input type="text" id="filterSearch" placeholder="ค้นหา PID / CID / SEQ..." onkeyup="renderErrorTable()">
<span class="count-badge" id="errCount">0 รายการ</span>
</div>
<div class="tbl-wrap" style="max-height:520px;overflow-y:auto">
<table id="errTable">
<thead>
<tr>
<th>#</th>
<th>Severity</th>
<th>Module</th>
<th>Row</th>
<th>PID</th>
<th>CID</th>
<th>SEQ</th>
<th>Field</th>
<th>ค่าปัจจุบัน</th>
<th>ค่าที่แนะนำ</th>
<th>คำอธิบาย</th>
<th>Action</th>
</tr>
</thead>
<tbody id="errTableBody"></tbody>
</table>
</div>
<div class="btn-group" style="margin-top:12px">
<button class="btn btn-warning" onclick="autoFixSafe()">⚡ Auto-fix ที่ปลอดภัย</button>
<button class="btn btn-outline" onclick="previewFixes()">👁️ Preview ก่อน Fix</button>
</div>
</div>
</div>
<!-- PAGE: PERSON -->
<div class="page" id="page-person">
<div class="card">
<div class="card-title"><div class="icon">👤</div> PERSON / TYPEAREA Audit</div>
<div id="personStats"></div>
</div>
<div class="card">
<div class="card-title"><div class="icon">📍</div> TYPEAREA Distribution</div>
<div id="typeareaDetail"></div>
</div>
<div class="card">
<div class="card-title"><div class="icon">✏️</div> แก้ TYPEAREA ใน PERSON</div>
<div class="filter-bar">
<span class="filter-label">PID รายคน:</span>
<input type="text" id="taPid" placeholder="เว้นว่าง = แก้ทั้งชุด">
<span class="filter-label">จาก</span>
<select id="taFrom">
<option value="">ทุกค่า</option>
<option value="1">1 ทะเบียนในเขตและอยู่จริง</option>
<option value="2">2 ทะเบียนในเขตแต่ตัวไม่อยู่</option>
<option value="3">3 อยู่จริงในเขต ทะเบียนนอกเขต</option>
<option value="4" selected>4 นอกเขตแต่มารับบริการ</option>
<option value="5">5 อื่นๆ</option>
</select>
<span class="filter-label">เป็น</span>
<select id="taTo">
<option value="1" selected>1 ทะเบียนในเขตและอยู่จริง</option>
<option value="2">2 ทะเบียนในเขตแต่ตัวไม่อยู่</option>
<option value="3">3 อยู่จริงในเขต ทะเบียนนอกเขต</option>
<option value="4">4 นอกเขตแต่มารับบริการ</option>
<option value="5">5 อื่นๆ</option>
</select>
<button class="btn btn-primary btn-sm" onclick="applyTypeareaEdit()">บันทึก TYPEAREA</button>
</div>
<div class="edit-help">ถ้าใส่ PID จะเปลี่ยนเฉพาะรายนั้น ถ้าเว้นว่างจะเปลี่ยนทุกคนที่ TYPEAREA ตรงกับช่อง “จาก” เช่น 4 → 1 หรือ 4 → 3 ทั้งชุด</div>
</div>
<div class="card" id="personIssuesCard" style="display:none">
<div class="card-title"><div class="icon">🚨</div> รายการที่ต้องตรวจสอบ</div>
<div id="personIssues"></div>
</div>
</div>
<!-- PAGE: CHRONIC -->
<div class="page" id="page-chronic">
<div class="card">
<div class="card-title"><div class="icon">🏥</div> CHRONIC Registry Audit</div>
<div id="chronicStats"></div>
</div>
<div class="card">
<div class="card-title"><div class="icon">📋</div> CHRONICFU Coverage</div>
<div id="chronicFuStats"></div>
</div>
<div class="card" id="chronicMissingFuCard">
<div class="card-title"><div class="icon">❗</div> CHRONIC ที่ไม่มี FU Visit</div>
<div id="chronicMissingFu"></div>
</div>
<div class="card" id="retinaAuditCard">
<div class="card-title"><div class="icon">👁️</div> ตรวจตาเบาหวาน / RETINA Audit</div>
<div class="filter-bar">
<span class="filter-label">ปีงบประมาณ:</span>
<input type="number" id="retinaFy" value="2569" style="width:100px">
<button class="btn btn-primary btn-sm" onclick="renderRetinaAudit()">คำนวณใหม่</button>
<button class="btn btn-outline btn-sm" onclick="exportRetinaAuditExcel()">Export รายชื่อ</button>
</div>
<div id="retinaAudit"></div>
</div>
<div class="card" id="chronicCandidateCard">
<div class="card-title"><div class="icon">💡</div> ควรพิจารณาขึ้นทะเบียน CHRONIC (มี Diag DM/HT แต่ไม่ได้ขึ้น)</div>
<div id="chronicCandidates"></div>
</div>
</div>
<!-- PAGE: LAB -->
<div class="page" id="page-lab">
<div class="card">
<div class="card-title"><div class="icon">🧪</div> LABFU Audit</div>
<div id="labStats"></div>
</div>
<div class="card">
<div class="card-title"><div class="icon">⚠️</div> Outlier Lab Results</div>
<div id="labOutliers"></div>
</div>
<div class="card">
<div class="card-title"><div class="icon">🔗</div> Lab Code ที่ไม่พบใน Mapping</div>
<div id="labUnmapped"></div>
</div>
</div>
<!-- PAGE: MAPPING -->
<div class="page" id="page-mapping">
<div class="card">
<div class="card-title"><div class="icon">🔗</div> Lab Code Mapping Table (แก้ไขได้)</div>
<div class="alert alert-info">📌 ตาราง mapping ใช้สำหรับแปลง Local Lab Code → HDC Standard Code ก่อน Upload เข้า HDC</div>
<div class="btn-group" style="margin-bottom:12px">
<button class="btn btn-primary btn-sm" onclick="addMappingRow()">➕ เพิ่ม mapping</button>
<button class="btn btn-outline btn-sm" onclick="exportMapping()">📥 Export Mapping</button>
</div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Local Code</th>
<th>Local Name</th>
<th>HDC Code</th>
<th>Category</th>
<th>Unit</th>
<th>สถานะ</th>
<th>Action</th>
</tr>
</thead>
<tbody id="mappingTableBody"></tbody>
</table>
</div>
</div>
</div>
<!-- PAGE: AUDIT LOG -->
<div class="page" id="page-audit">
<div class="card">
<div class="card-title"><div class="icon">📋</div> Audit Log (ประวัติการแก้ไขทั้งหมด)</div>
<div class="filter-bar">
<select id="logFilter" onchange="renderAuditLog()">
<option value="">ทุก Action</option>
<option value="AUTO_FIX">Auto-fix</option>
<option value="MANUAL_EDIT">Manual Edit</option>
<option value="IGNORE">Ignore</option>
<option value="RESTORE">Restore</option>
</select>
<span class="count-badge" id="logCount">0 รายการ</span>
</div>
<div id="auditLogList"></div>
</div>
</div>
<!-- PAGE: EXPORT -->
<div class="page" id="page-export">
<div class="card">
<div class="card-title"><div class="icon">💾</div> Export ไฟล์</div>
<div class="alert alert-warning">⚠️ ก่อน Export ควรตรวจสอบ Preview Fix และยืนยันการแก้ไขทั้งหมดแล้ว</div>
<div style="display:flex;flex-direction:column;gap:16px;margin-top:16px">
<div class="card" style="border:2px solid var(--primary);margin:0">
<div style="font-weight:700;font-size:15px;margin-bottom:8px;color:var(--primary-dark)">📦 ZIP 43 แฟ้มที่แก้ไขแล้ว</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:12px">Export ไฟล์ทุกแฟ้มที่มีการแก้ไข พร้อมคงโครงสร้าง column เดิม</div>
<button class="btn btn-primary" onclick="exportFixedZip()">📦 Export ZIP</button>
</div>
<div class="card" style="border:2px solid var(--warning);margin:0">
<div style="font-weight:700;font-size:15px;margin-bottom:8px;color:#92400e">📊 Excel Error Report</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:12px">รายงาน Error ทั้งหมดในรูปแบบ Excel (.xlsx)</div>
<button class="btn btn-warning" onclick="exportErrorExcel()">📊 Export Excel</button>
</div>
<div class="card" style="border:2px solid var(--info);margin:0">
<div style="font-weight:700;font-size:15px;margin-bottom:8px;color:#0369a1">📋 Excel Audit Log</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:12px">บันทึกการแก้ไขทั้งหมดในรูปแบบ Excel (.xlsx)</div>
<button class="btn btn-info" onclick="exportAuditExcel()">📋 Export Audit Log</button>
</div>
</div>
</div>
</div>
</div><!-- /container -->
<script>
// ========== GLOBALS ==========
let state = {
files: {}, // filename -> { header:[], rows:[[]], rawText:string }
errors: [],
auditLog: [],
fixes: {}, // filename -> { rowIdx -> {colIdx -> newVal} }
originals: {}, // filename -> originalText
hcode: '11422',
checked: false,
charts: {}
};
// ========== LAB MAPPING (Default) ==========
let labMapping = [
{ local:'0531601', localName:'HbA1c', hdc:'0531601', category:'HbA1c', unit:'%', active:true },
{ local:'0531002', localName:'FBS (Glucose)', hdc:'0531001', category:'FBS', unit:'mg/dL', active:true },
{ local:'0531102', localName:'FBS 2hr (OGTT)', hdc:'0531101', category:'FBS', unit:'mg/dL', active:true },
{ local:'0510402', localName:'FBS (ห้อง lab)', hdc:'0531001', category:'FBS', unit:'mg/dL', active:true },
{ local:'0511402', localName:'FBS-2', hdc:'0531001', category:'FBS', unit:'mg/dL', active:true },
{ local:'0541402', localName:'LDL Cholesterol', hdc:'0541401', category:'LDL', unit:'mg/dL', active:true },
{ local:'0621201', localName:'Creatinine', hdc:'0621201', category:'Creatinine', unit:'mg/dL', active:true },
{ local:'0581904', localName:'eGFR (CKD-EPI)', hdc:'0581904', category:'eGFR', unit:'mL/min/1.73m²', active:true },
{ local:'0581902', localName:'eGFR (MDRD)', hdc:'0581902', category:'eGFR', unit:'mL/min/1.73m²', active:true },
{ local:'0583001', localName:'Microalbumin (urine)', hdc:'0583001', category:'Microalbumin', unit:'mg/L', active:true },
{ local:'0440205', localName:'Total Cholesterol', hdc:'0440201', category:'Lipid', unit:'mg/dL', active:true },
{ local:'0440203', localName:'Triglyceride', hdc:'0440203', category:'Lipid', unit:'mg/dL', active:true },
{ local:'0531802', localName:'TG (Triglyceride)', hdc:'0440203', category:'Lipid', unit:'mg/dL', active:true },
{ local:'0743299', localName:'Urine protein', hdc:'0743299', category:'Urine', unit:'-', active:true },
{ local:'0721297', localName:'Urine glucose', hdc:'0721297', category:'Urine', unit:'-', active:true },
{ local:'0746299', localName:'Urine ketone', hdc:'0746299', category:'Urine', unit:'-', active:true },
{ local:'0749200', localName:'Urine dipstick', hdc:'0749200', category:'Urine', unit:'-', active:true },
{ local:'0749202', localName:'Urine albumin', hdc:'0749202', category:'Urine', unit:'mg/g Cr', active:true },
{ local:'0741699', localName:'WBC', hdc:'0741699', category:'CBC', unit:'/mm³', active:true },
{ local:'0741499', localName:'Hb/Hct', hdc:'0741499', category:'CBC', unit:'g/dL', active:true },
];
// ========== UTILITY ==========
function $(id) { return document.getElementById(id); }
function fmtNum(n) { return n?.toLocaleString('th-TH') ?? '—'; }
function fmtDate(s) {
if (!s) return '—';
s = String(s).trim();
if (s.length === 8) return s.slice(0,4)+'-'+s.slice(4,6)+'-'+s.slice(6,8);
return s;
}
function isValidDate8(s) {
if (!s || s.length !== 8 || !/^\d{8}$/.test(s)) return false;
const y = parseInt(s.slice(0,4)), m = parseInt(s.slice(4,6)), d = parseInt(s.slice(6,8));
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
return true;
}
function isICD10(s) {
if (!s) return false;
return /^[A-Z]\d{2,4}(\.\d{1,2})?$/.test(s.trim().toUpperCase());
}
function severityOrder(s) {
return { critical:0, high:1, medium:2, low:3 }[s] ?? 4;
}
function escapeHtml(v) {
return String(v ?? '').replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
}
function ymdToday() {
const d = new Date();
return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`;
}
function currentThaiFiscalYear() {
const d = new Date();
const buddhistYear = d.getFullYear() + 543;
return d.getMonth() >= 9 ? buddhistYear + 1 : buddhistYear;
}
function fiscalRange(thaiFy) {
const adFy = Number(thaiFy) - 543;
return { start: `${adFy-1}1001`, end: `${adFy}0930` };
}
function addError(sev, module, file, rowNum, pid, cid, seq, field, curVal, sugVal, desc, action='manual') {
state.errors.push({ sev, module, file, rowNum, pid, cid, seq, field, curVal: String(curVal??''), sugVal: String(sugVal??''), desc, action });
}
function addLog(action, detail, before='', after='') {
const now = new Date();
state.auditLog.push({
time: now.toLocaleString('th-TH'),
action, detail, before, after
});
}
function loading(show, text='กำลังประมวลผล...', sub='กรุณารอสักครู่') {
const el = $('loadingOverlay');
$('loadingText').textContent = text;
$('loadingSub').textContent = sub;
el.classList.toggle('show', show);
}
function showPage(name) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.navtab').forEach(t => t.classList.remove('active'));
$('page-'+name).classList.add('active');
$('tab-'+name).classList.add('active');
}
function openModal(title, body, footer='') {
$('modalTitle').textContent = title;
$('modalBody').innerHTML = body;
$('modalFooter').innerHTML = `<button class="btn btn-outline btn-sm" onclick="closeModal()">ปิด</button>` + footer;
$('modalBackdrop').classList.add('show');
}
function closeModal() { $('modalBackdrop').classList.remove('show'); }
function setProgress(pct, label, sub='') {
$('progressFill').style.width = pct + '%';
$('progressPct').textContent = Math.round(pct) + '%';
$('progressLabel').textContent = label;
if (sub) $('statusText').textContent = sub;
}
// ========== ENCODING DETECTION ==========
function detectAndDecode(buffer) {
// Try UTF-8 first
try {
const utf8 = new TextDecoder('utf-8', {fatal:true}).decode(buffer);
// Verify it has Thai characters or pipe delimiters
if (utf8.includes('|')) return utf8;
} catch(e) {}
// Try TIS-620 / Windows-874
try {
return new TextDecoder('windows-874').decode(buffer);
} catch(e) {}
return new TextDecoder('utf-8', {fatal:false}).decode(buffer);
}
// ========== FILE PARSING ==========
function parseTxt(text) {
const lines = text.replace(/\r\n/g,'\n').replace(/\r/g,'\n').split('\n').filter(l => l.trim());
if (lines.length === 0) return { header:[], rows:[] };
const header = lines[0].split('|');
const rows = lines.slice(1).map(l => l.split('|'));
return { header, rows };
}
function colIdx(file, colName) {
const f = state.files[file];
if (!f) return -1;
return f.header.indexOf(colName);
}
function getVal(file, row, colName) {
const idx = colIdx(file, colName);
if (idx < 0) return '';
return (row[idx] ?? '').trim();
}
function isDmCode(code) {
return /^E1[0-4]/.test(String(code || '').trim().toUpperCase());
}
function serializeFile(file) {
const f = state.files[file];
if (!f) return '';
const lines = [f.header.join('|'), ...f.rows.map(row => {
const out = [];
for (let i = 0; i < f.header.length; i++) out.push(String(row[i] ?? ''));
return out.join('|');
})];
return lines.join('\r\n') + '\r\n';
}
function touchFile(file) {
if (state.files[file]) state.files[file].rawText = serializeFile(file);
}
function ensureFile(file, header) {
if (state.files[file]) return state.files[file];
state.files[file] = { header:[...header], rows:[], rawText:'' };
state.originals[file] = '';
touchFile(file);
renderFileSummary();
return state.files[file];
}
function setCellValue(file, rowNum, field, value) {
const f = state.files[file];
if (!f) throw new Error(`ไม่พบไฟล์ ${file}`);
const rowIdx = Number(rowNum) - 2;
if (rowIdx < 0 || rowIdx >= f.rows.length) throw new Error(`row ${rowNum} ไม่สามารถแก้แบบ cell ได้`);
const c = colIdx(file, field);
if (c < 0) throw new Error(`ไม่พบ column ${field} ใน ${file}`);
const before = String(f.rows[rowIdx][c] ?? '');
f.rows[rowIdx][c] = String(value ?? '');
if (!state.fixes[file]) state.fixes[file] = {};
if (!state.fixes[file][rowNum]) state.fixes[file][rowNum] = {};
state.fixes[file][rowNum][field] = String(value ?? '');
touchFile(file);
return before;
}
function appendRow(file, values) {
const f = state.files[file];
if (!f) throw new Error(`ไม่พบไฟล์ ${file}`);
const row = f.header.map(h => String(values[h] ?? ''));
f.rows.push(row);
touchFile(file);
return f.rows.length + 1;
}
function findRowNumByPid(file, pid) {
const f = state.files[file];
if (!f || !pid) return 0;
const idx = f.rows.findIndex(r => getVal(file, r, 'PID') === String(pid));
return idx >= 0 ? idx + 2 : 0;
}
function personCidByPid(pid) {
const rowNum = findRowNumByPid('person.txt', pid);
if (!rowNum) return '';
return getVal('person.txt', state.files['person.txt'].rows[rowNum - 2], 'CID');
}
function findServiceBySeq(seq) {
const sf = state.files['service.txt'];
if (!sf || !seq) return null;
return sf.rows.find(r => getVal('service.txt', r, 'SEQ') === String(seq)) || null;
}
function latestRowByPid(file, pid, dateField='DATE_SERV') {
const f = state.files[file];
if (!f || !pid) return null;
return f.rows
.filter(r => getVal(file, r, 'PID') === String(pid))
.sort((a,b) => getVal(file, b, dateField).localeCompare(getVal(file, a, dateField)))[0] || null;
}
function refreshChecksAfterEdit(keepPage='errors') {
if (!Object.keys(state.files).length) return;
state.errors = [];
checkPerson();
checkService();
checkChronic();
checkChronicFu();
checkLabFu();
checkDiagnosisOpd();
checkProcedureOpd();
state.checked = true;
renderDashboard();
renderErrorTable();
renderPersonPage();
renderChronicPage();
renderRetinaAudit();
renderLabPage();
renderMappingTable();
const files = [...new Set(state.errors.map(e=>e.file))].sort();
$('filterFile').innerHTML = '<option value="">ทุกไฟล์</option>' + files.map(f=>`<option value="${f}">${f}</option>`).join('');
$('errBadge').textContent = state.errors.filter(e => e.sev === 'critical' || e.sev === 'high').length;
$('errBadge').className = 'badge' + (state.errors.some(e=>e.sev==='critical')?' ':' warn');
showPage(keepPage);
}
// ========== FILE UPLOAD ==========
function handleFileSelect(e) {
const file = e.target.files[0];
if (!file) return;
processZip(file);
}
const dropzone = $('dropzone');
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('drag'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag'));
dropzone.addEventListener('drop', e => {
e.preventDefault();
dropzone.classList.remove('drag');
const file = e.dataTransfer.files[0];
if (file) processZip(file);
});
async function processZip(file) {
$('progressWrap').style.display = 'block';
setProgress(5, 'กำลังเปิด ZIP...', file.name);
loading(true, 'กำลังเปิด ZIP', file.name);
try {
const buf = await file.arrayBuffer();
const zip = await JSZip.loadAsync(buf);
const entries = Object.values(zip.files).filter(f => !f.dir && f.name.endsWith('.txt'));
const total = entries.length;
let done = 0;
state.files = {};
state.originals = {};
state.fixes = {};
for (const entry of entries) {
const fname = entry.name.split('/').pop().toLowerCase();
const rawBuf = await entry.async('arraybuffer');
const rawText = detectAndDecode(rawBuf);
const parsed = parseTxt(rawText);
state.files[fname] = { ...parsed, rawText };
state.originals[fname] = rawText;
done++;
setProgress(5 + (done/total)*60, `อ่านไฟล์: ${fname}`, `${done}/${total} ไฟล์`);
}
// Detect HCODE
const personFile = state.files['person.txt'];
if (personFile && personFile.rows.length > 0) {
state.hcode = personFile.rows[0][0] || '11422';
$('topHcode').textContent = 'HCODE: ' + state.hcode;
}
setProgress(70, 'สร้างสรุปไฟล์...');
renderFileSummary();
setProgress(100, 'เสร็จสิ้น ✓', 'พร้อมตรวจสอบ');
$('fileSummarySection').style.display = 'block';
loading(false);
addLog('UPLOAD', `โหลดไฟล์ ZIP สำเร็จ ${total} ไฟล์`, '', file.name);
} catch(err) {
loading(false);
setProgress(0, 'เกิดข้อผิดพลาด', err.message);
alert('ไม่สามารถเปิดไฟล์ ZIP ได้: ' + err.message);
}
}
// ========== FILE SUMMARY ==========
function renderFileSummary() {
const files = Object.entries(state.files);
const requiredFiles = ['person.txt','service.txt','diagnosis_opd.txt','chronic.txt','chronicfu.txt','labfu.txt','procedure_opd.txt','death.txt','address.txt'];
const totalRows = files.reduce((s,[,f]) => s + f.rows.length, 0);
const personRows = state.files['person.txt']?.rows.length ?? 0;
const serviceRows = state.files['service.txt']?.rows.length ?? 0;
const missingCore = requiredFiles.filter(f => !state.files[f]).length;
$('fileSummaryKpi').innerHTML = `
<div class="kpi-card blue"><div class="kpi-val">${files.length}</div><div class="kpi-label">จำนวนไฟล์</div></div>
<div class="kpi-card green"><div class="kpi-val">${fmtNum(personRows)}</div><div class="kpi-label">PERSON</div></div>
<div class="kpi-card green"><div class="kpi-val">${fmtNum(serviceRows)}</div><div class="kpi-label">SERVICE visit</div></div>
<div class="kpi-card ${missingCore>0?'red':'green'}"><div class="kpi-val ${missingCore>0?'red':''}">${missingCore}</div><div class="kpi-label">ไฟล์หลักที่หาย</div></div>
<div class="kpi-card blue"><div class="kpi-val">${fmtNum(totalRows)}</div><div class="kpi-label">แถวทั้งหมด</div></div>
`;
$('fileGrid').innerHTML = files.map(([name, f]) => {
const isRequired = requiredFiles.includes(name);
return `<div class="file-item">
<span class="fi-icon">${isRequired?'📌':'📄'}</span>
<div>
<div class="fi-name">${name}</div>
<div class="fi-info">${fmtNum(f.rows.length)} แถว | ${f.header.length} col</div>
</div>
<div class="fi-status"><span class="dot dot-ok"></span></div>
</div>`;
}).join('') + requiredFiles.filter(f => !state.files[f]).map(name => `
<div class="file-item" style="border-color:var(--danger);background:#fff5f5">
<span class="fi-icon">❌</span>
<div>
<div class="fi-name" style="color:var(--danger)">${name}</div>
<div class="fi-info" style="color:var(--danger)">ไม่พบไฟล์</div>
</div>
<div class="fi-status"><span class="dot dot-err"></span></div>
</div>`).join('');
}
// ========== MAIN CHECK RUNNER ==========
async function runAllChecks() {
if (!Object.keys(state.files).length) { alert('กรุณาอัปโหลดไฟล์ก่อน'); return; }
loading(true, 'กำลังตรวจสอบ...', 'โปรดรอ');
state.errors = [];
await sleep(50);
loading(true, 'ตรวจสอบ PERSON...', '');
checkPerson();
await sleep(50);
loading(true, 'ตรวจสอบ SERVICE Linkage...', '');
checkService();
await sleep(50);
loading(true, 'ตรวจสอบ CHRONIC...', '');
checkChronic();
await sleep(50);
loading(true, 'ตรวจสอบ CHRONICFU...', '');
checkChronicFu();
await sleep(50);
loading(true, 'ตรวจสอบ LABFU...', '');
checkLabFu();
await sleep(50);
loading(true, 'ตรวจสอบ DIAGNOSIS_OPD...', '');
checkDiagnosisOpd();
await sleep(50);
loading(true, 'ตรวจสอบ PROCEDURE_OPD...', '');
checkProcedureOpd();
await sleep(50);
loading(true, 'สร้าง Dashboard...', '');
state.checked = true;
renderDashboard();
renderErrorTable();
renderPersonPage();
renderChronicPage();
renderRetinaAudit();
renderLabPage();
renderMappingTable();
$('errBadge').textContent = state.errors.filter(e => e.sev === 'critical' || e.sev === 'high').length;
$('errBadge').className = 'badge' + (state.errors.some(e=>e.sev==='critical')?' ':' warn');
// Populate filter selects
const files = [...new Set(state.errors.map(e=>e.file))].sort();
$('filterFile').innerHTML = '<option value="">ทุกไฟล์</option>' + files.map(f=>`<option value="${f}">${f}</option>`).join('');
addLog('CHECK', `ตรวจสอบเสร็จสิ้น พบ ${state.errors.length} รายการ error`);
loading(false);
showPage('dashboard');
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// ========== CHECK: PERSON ==========
function checkPerson() {
const f = state.files['person.txt'];
if (!f) { addError('critical','PERSON','person.txt',0,'','','','','','','ไม่พบไฟล์ person.txt'); return; }
const seen_pid = new Map(), seen_cid = new Map();
const typeareas = { 1:0, 2:0, 3:0, 4:0, 5:0, other:0 };
f.rows.forEach((row, i) => {
const ri = i + 2;
const pid = getVal('person.txt', row, 'PID');
const cid = getVal('person.txt', row, 'CID');
const typearea = getVal('person.txt', row, 'TYPEAREA');
const discharge = getVal('person.txt', row, 'DISCHARGE');
const hospcode = getVal('person.txt', row, 'HOSPCODE');
if (!pid) addError('critical','PERSON','person.txt',ri,pid,cid,'','PID','','','PID ว่างเปล่า');
if (!cid) addError('high','PERSON','person.txt',ri,pid,cid,'','CID','','','CID ว่างเปล่า');
if (cid && cid.length !== 13) addError('medium','PERSON','person.txt',ri,pid,cid,'','CID',cid,'','CID ไม่ใช่ 13 หลัก');
if (pid && seen_pid.has(pid)) addError('critical','PERSON','person.txt',ri,pid,cid,'','PID',pid,'','PID ซ้ำกับ row '+seen_pid.get(pid),'manual');
else if (pid) seen_pid.set(pid, ri);
if (cid && seen_cid.has(cid)) addError('critical','PERSON','person.txt',ri,pid,cid,'','CID',cid,'','CID ซ้ำกับ row '+seen_cid.get(cid),'manual');
else if (cid) seen_cid.set(cid, ri);
if (!typearea) addError('high','PERSON','person.txt',ri,pid,cid,'','TYPEAREA','','4','TYPEAREA ว่างเปล่า','auto');
else if (!['1','2','3','4','5'].includes(typearea)) addError('high','PERSON','person.txt',ri,pid,cid,'','TYPEAREA',typearea,'4','TYPEAREA ไม่ใช่ 1-5','auto');
if (hospcode !== state.hcode) addError('medium','PERSON','person.txt',ri,pid,cid,'','HOSPCODE',hospcode,state.hcode,'HOSPCODE ไม่ตรง');
const ta = parseInt(typearea);
if ([1,2,3,4,5].includes(ta)) typeareas[ta]++;
else typeareas.other++;
});
// Check DEATH cross-reference
const deathFile = state.files['death.txt'];
const personPidSet = new Map(f.rows.map(r => [getVal('person.txt',r,'PID'), r]));
if (deathFile) {
deathFile.rows.forEach((row, i) => {
const pid = getVal('death.txt', row, 'PID');
if (pid && personPidSet.has(pid)) {
const prow = personPidSet.get(pid);
const disc = getVal('person.txt', prow, 'DISCHARGE');
if (!disc || disc === '0' || disc === '') {
addError('high','PERSON','person.txt',0,pid,'','','DISCHARGE','','4','มีใน DEATH แต่ PERSON ยังไม่ update DISCHARGE=4','auto');
}
}
});
}
// Warnings for unusual typearea
const total = f.rows.length;
if (typeareas[1] < total * 0.1) addError('medium','PERSON','person.txt',0,'','','','TYPEAREA','TYPEAREA 1='+typeareas[1],'','TYPEAREA 1 (ทะเบียนในเขตและอยู่จริง) น้อยผิดปกติ (<10%)', 'ignore');
if (typeareas[4] > total * 0.8) addError('medium','PERSON','person.txt',0,'','','','TYPEAREA','TYPEAREA 4='+typeareas[4],'','TYPEAREA 4 (นอกเขตแต่มารับบริการ) สูงผิดปกติ (>80%)', 'ignore');
// Store for page render
state._personTypeareas = typeareas;
state._personTotal = f.rows.length;
}
// ========== CHECK: SERVICE ==========
function checkService() {
const sf = state.files['service.txt'];
const pf = state.files['person.txt'];
if (!sf || !pf) return;
const personPids = new Set(pf.rows.map(r => getVal('person.txt',r,'PID')));
const seen_seq = new Map();
const diagSeqs = new Set(state.files['diagnosis_opd.txt']?.rows.map(r => getVal('diagnosis_opd.txt',r,'SEQ')) ?? []);
const procSeqs = new Set(state.files['procedure_opd.txt']?.rows.map(r => getVal('procedure_opd.txt',r,'SEQ')) ?? []);
sf.rows.forEach((row, i) => {
const ri = i + 2;
const pid = getVal('service.txt', row, 'PID');
const seq = getVal('service.txt', row, 'SEQ');
const date = getVal('service.txt', row, 'DATE_SERV');
const hospcode = getVal('service.txt', row, 'HOSPCODE');
if (pid && !personPids.has(pid)) addError('high','SERVICE','service.txt',ri,pid,'',seq,'PID',pid,'','PID ไม่พบใน PERSON');
if (!seq) addError('critical','SERVICE','service.txt',ri,pid,'',seq,'SEQ','','','SEQ ว่างเปล่า');
if (seq && seen_seq.has(seq)) addError('critical','SERVICE','service.txt',ri,pid,'',seq,'SEQ',seq,'','SEQ ซ้ำกับ row '+seen_seq.get(seq),'manual');
else if (seq) seen_seq.set(seq, ri);
if (date && !isValidDate8(date)) addError('medium','SERVICE','service.txt',ri,pid,'',seq,'DATE_SERV',date,'YYYYMMDD','รูปแบบวันที่ผิด ต้องเป็น YYYYMMDD');
if (hospcode !== state.hcode) addError('medium','SERVICE','service.txt',ri,pid,'',seq,'HOSPCODE',hospcode,state.hcode,'HOSPCODE ไม่ตรง');
});
// SERVICE SEQ ที่ไม่มี DIAG
const serviceSeqs = new Set(sf.rows.map(r => getVal('service.txt',r,'SEQ')).filter(Boolean));
const diagSvc = state.files['diagnosis_opd.txt'];
if (diagSvc) {
const seqsWithDiag = new Set(diagSvc.rows.map(r => getVal('diagnosis_opd.txt',r,'SEQ')).filter(Boolean));
let noDiagCount = 0;
serviceSeqs.forEach(seq => { if (!seqsWithDiag.has(seq)) noDiagCount++; });
if (noDiagCount > 0) addError('high','SERVICE','service.txt',0,'','','','SEQ','','',`SERVICE ${noDiagCount} visit ไม่มี DIAGNOSIS_OPD`,'ignore');
}
state._serviceSeqs = serviceSeqs;
}
// ========== CHECK: CHRONIC ==========
function checkChronic() {
const cf = state.files['chronic.txt'];
const pf = state.files['person.txt'];
if (!cf || !pf) return;
const personPids = new Set(pf.rows.map(r => getVal('person.txt',r,'PID')));
const chronicCodes = {
DM: /^E1[0-4]/,
HT: /^I1[0-5]/,
CKD: /^N18/,
COPD: /^J44/,
ASTHMA: /^J45/,
STROKE: /^I6[0-9]/,
IHD: /^I2[0-5]/
};
const chronicPids = new Set();
cf.rows.forEach((row, i) => {
const ri = i + 2;
const pid = getVal('chronic.txt', row, 'PID');
const cid = getVal('chronic.txt', row, 'CID');
const chronic = getVal('chronic.txt', row, 'CHRONIC');
const dateDiag = getVal('chronic.txt', row, 'DATE_DIAG');
const hospcode = getVal('chronic.txt', row, 'HOSPCODE');
if (pid) chronicPids.add(pid);
if (pid && !personPids.has(pid)) addError('high','CHRONIC','chronic.txt',ri,pid,cid,'','PID',pid,'','PID ไม่พบใน PERSON');
if (!chronic) addError('high','CHRONIC','chronic.txt',ri,pid,cid,'','CHRONIC','','','CHRONIC code ว่างเปล่า');
else if (!isICD10(chronic)) addError('medium','CHRONIC','chronic.txt',ri,pid,cid,'','CHRONIC',chronic,'','CHRONIC code ไม่ใช่ ICD-10');
if (dateDiag && !isValidDate8(dateDiag)) addError('medium','CHRONIC','chronic.txt',ri,pid,cid,'','DATE_DIAG',dateDiag,'YYYYMMDD','รูปแบบวันที่ผิด');
if (hospcode !== state.hcode) addError('low','CHRONIC','chronic.txt',ri,pid,cid,'','HOSPCODE',hospcode,state.hcode,'HOSPCODE ไม่ตรง');
});
state._chronicPids = chronicPids;
// Check Diag DM/HT without CHRONIC
const diagFile = state.files['diagnosis_opd.txt'];
if (diagFile) {
const dmhtPids = new Set();
const candidateInfo = {};
diagFile.rows.forEach(row => {
const diag = getVal('diagnosis_opd.txt', row, 'DIAGCODE');
const pid = getVal('diagnosis_opd.txt', row, 'PID');
if (diag && (chronicCodes.DM.test(diag) || chronicCodes.HT.test(diag))) {
if (!chronicPids.has(pid)) {
dmhtPids.add(pid);
const dateServ = getVal('diagnosis_opd.txt', row, 'DATE_SERV');
const current = candidateInfo[pid];
if (!current || dateServ > current.dateServ) {
candidateInfo[pid] = { pid, chronic: diag, dateServ, cid: getVal('diagnosis_opd.txt', row, 'CID'), seq: getVal('diagnosis_opd.txt', row, 'SEQ') };
}
}
}
});
state._candidatePids = dmhtPids;
state._candidateChronicInfo = candidateInfo;
if (dmhtPids.size > 0) addError('medium','CHRONIC','diagnosis_opd.txt',0,'','','','CHRONIC','','',`มี ${dmhtPids.size} คนที่มี Diag DM/HT แต่ไม่มีใน CHRONIC ควรพิจารณาขึ้นทะเบียน`,'ignore');
}
state._chronicCodes = chronicCodes;
}
// ========== CHECK: CHRONICFU ==========
function checkChronicFu() {
const cf = state.files['chronicfu.txt'];
const chronicPids = state._chronicPids || new Set();
const pf = state.files['person.txt'];
if (!pf) return;
const personPids = new Set(pf.rows.map(r => getVal('person.txt',r,'PID')));
const fuPids = new Set();
const requiredFields = ['WEIGHT','HEIGHT','WAIST_CM','SBP','DBP','FOOT','RETINA','SMOKING','ALCOHOL','PROVIDER']
.filter(field => colIdx('chronicfu.txt', field) >= 0);
if (cf) {
cf.rows.forEach((row, i) => {
const ri = i + 2;
const pid = getVal('chronicfu.txt', row, 'PID');
const seq = getVal('chronicfu.txt', row, 'SEQ');
const cid = getVal('chronicfu.txt', row, 'CID');
const date = getVal('chronicfu.txt', row, 'DATE_SERV');
const hospcode = getVal('chronicfu.txt', row, 'HOSPCODE');
if (pid) fuPids.add(pid);
if (pid && !personPids.has(pid)) addError('high','CHRONICFU','chronicfu.txt',ri,pid,cid,seq,'PID',pid,'','PID ไม่พบใน PERSON');
if (date && !isValidDate8(date)) addError('medium','CHRONICFU','chronicfu.txt',ri,pid,cid,seq,'DATE_SERV',date,'YYYYMMDD','รูปแบบวันที่ผิด');
if (hospcode !== state.hcode) addError('low','CHRONICFU','chronicfu.txt',ri,pid,cid,seq,'HOSPCODE',hospcode,state.hcode,'HOSPCODE ไม่ตรง');
requiredFields.forEach(field => {
const val = getVal('chronicfu.txt', row, field);
if (!val) addError('low','CHRONICFU','chronicfu.txt',ri,pid,cid,seq,field,'','',`CHRONICFU ไม่มีค่า ${field}`);
});
});
}
// CHRONIC ที่ไม่มี FU
const noFuPids = [...chronicPids].filter(pid => !fuPids.has(pid));
state._noFuPids = noFuPids;
if (noFuPids.length > 0) addError('high','CHRONICFU','chronicfu.txt',0,'','','','FU','','',`CHRONIC ${noFuPids.length} ราย ไม่มี CHRONICFU record`,'auto');
state._fuPids = fuPids;
}
// ========== CHECK: LABFU ==========
function checkLabFu() {
const lf = state.files['labfu.txt'];
const pf = state.files['person.txt'];
if (!lf || !pf) return;
const personPids = new Set(pf.rows.map(r => getVal('person.txt',r,'PID')));
const mappedCodes = new Set(labMapping.map(m => m.local));
const unmappedCodes = new Map();
const outliers = [];
const outlierRules = {
HbA1c: { codes: new Set(['0531601']), min:0, max:20 },
FBS: { codes: new Set(['0531001','0531002','0531102','0510402','0511402']), min:0, max:1000 },
LDL: { codes: new Set(['0541401','0541402']), min:0, max:500 },
Creatinine: { codes: new Set(['0621201']), min:0, max:20 },
};
lf.rows.forEach((row, i) => {
const ri = i + 2;
const pid = getVal('labfu.txt', row, 'PID');
const cid = getVal('labfu.txt', row, 'CID');
const seq = getVal('labfu.txt', row, 'SEQ');
const labtest = getVal('labfu.txt', row, 'LABTEST');
const labresult = getVal('labfu.txt', row, 'LABRESULT');
const date = getVal('labfu.txt', row, 'DATE_SERV');
const hospcode = getVal('labfu.txt', row, 'HOSPCODE');
if (pid && !personPids.has(pid)) addError('high','LABFU','labfu.txt',ri,pid,cid,seq,'PID',pid,'','PID ไม่พบใน PERSON');
if (!labtest) addError('medium','LABFU','labfu.txt',ri,pid,cid,seq,'LABTEST','','','LABTEST ว่างเปล่า');
else if (!mappedCodes.has(labtest)) {
unmappedCodes.set(labtest, (unmappedCodes.get(labtest)||0) + 1);
addError('low','LABFU','labfu.txt',ri,pid,cid,seq,'LABTEST',labtest,'','LABTEST code ไม่พบใน mapping table','auto');
}
if (!labresult) addError('medium','LABFU','labfu.txt',ri,pid,cid,seq,'LABRESULT','','','LABRESULT ว่างเปล่า');
else {
// Clean result (remove non-numeric suffix like [Stage 2])
const numResult = parseFloat(labresult.replace(/[^\d.]/g,''));
for (const [cat, rule] of Object.entries(outlierRules)) {
if (rule.codes.has(labtest) && !isNaN(numResult)) {
if (numResult < rule.min || numResult > rule.max) {
outliers.push({ ri, pid, cid, seq, labtest, labresult, cat });
addError('medium','LABFU','labfu.txt',ri,pid,cid,seq,'LABRESULT',labresult,'','ค่า '+cat+' outlier ('+numResult+'): ตรวจสอบก่อน Upload','ignore');
}
}
}
}
if (date && !isValidDate8(date)) addError('medium','LABFU','labfu.txt',ri,pid,cid,seq,'DATE_SERV',date,'YYYYMMDD','รูปแบบวันที่ผิด');
if (hospcode !== state.hcode) addError('low','LABFU','labfu.txt',ri,pid,cid,seq,'HOSPCODE',hospcode,state.hcode,'HOSPCODE ไม่ตรง');
});
state._unmappedLabCodes = unmappedCodes;
state._labOutliers = outliers;
}
// ========== CHECK: DIAGNOSIS_OPD ==========
function checkDiagnosisOpd() {
const df = state.files['diagnosis_opd.txt'];
const pf = state.files['person.txt'];
if (!df || !pf) return;
const personPids = new Set(pf.rows.map(r => getVal('person.txt',r,'PID')));
const serviceSeqs = state._serviceSeqs || new Set();
const seqDiagTypes = new Map(); // seq -> Set of diagtypes
df.rows.forEach((row, i) => {
const ri = i + 2;
const pid = getVal('diagnosis_opd.txt', row, 'PID');
const seq = getVal('diagnosis_opd.txt', row, 'SEQ');
const cid = getVal('diagnosis_opd.txt', row, 'CID');
const diag = getVal('diagnosis_opd.txt', row, 'DIAGCODE');
const dtype = getVal('diagnosis_opd.txt', row, 'DIAGTYPE');
if (pid && !personPids.has(pid)) addError('high','DIAGNOSIS_OPD','diagnosis_opd.txt',ri,pid,cid,seq,'PID',pid,'','PID ไม่พบใน PERSON');
if (seq && !serviceSeqs.has(seq)) addError('medium','DIAGNOSIS_OPD','diagnosis_opd.txt',ri,pid,cid,seq,'SEQ',seq,'','SEQ ไม่พบใน SERVICE');
if (!diag) addError('high','DIAGNOSIS_OPD','diagnosis_opd.txt',ri,pid,cid,seq,'DIAGCODE','','','DIAGCODE ว่างเปล่า');
else if (!isICD10(diag.replace('.','').trim())) addError('medium','DIAGNOSIS_OPD','diagnosis_opd.txt',ri,pid,cid,seq,'DIAGCODE',diag,'','DIAGCODE ไม่ใช่รูปแบบ ICD-10');
if (!dtype) addError('medium','DIAGNOSIS_OPD','diagnosis_opd.txt',ri,pid,cid,seq,'DIAGTYPE','','1','DIAGTYPE ว่างเปล่า');
else if (!['1','2','3','4','5'].includes(dtype)) addError('medium','DIAGNOSIS_OPD','diagnosis_opd.txt',ri,pid,cid,seq,'DIAGTYPE',dtype,'','DIAGTYPE ไม่ใช่ 1-5');
if (seq) {
if (!seqDiagTypes.has(seq)) seqDiagTypes.set(seq, new Set());
seqDiagTypes.get(seq).add(dtype);
}
});
// Visit ไม่มี primary diag
let noPrimary = 0;
seqDiagTypes.forEach((types, seq) => { if (!types.has('1')) noPrimary++; });
if (noPrimary > 0) addError('high','DIAGNOSIS_OPD','diagnosis_opd.txt',0,'','','','DIAGTYPE','','',''+noPrimary+' visit ไม่มี Primary Diagnosis (DIAGTYPE=1)','ignore');
}
// ========== CHECK: PROCEDURE_OPD ==========
function checkProcedureOpd() {
const pf2 = state.files['procedure_opd.txt'];
const pf = state.files['person.txt'];
if (!pf2 || !pf) return;
const personPids = new Set(pf.rows.map(r => getVal('person.txt',r,'PID')));
const serviceSeqs = state._serviceSeqs || new Set();
pf2.rows.forEach((row, i) => {
const ri = i + 2;
const pid = getVal('procedure_opd.txt', row, 'PID');
const seq = getVal('procedure_opd.txt', row, 'SEQ');
const cid = getVal('procedure_opd.txt', row, 'CID');
const proc = getVal('procedure_opd.txt', row, 'PROCEDCODE');
const date = getVal('procedure_opd.txt', row, 'DATE_SERV');
if (pid && !personPids.has(pid)) addError('high','PROCEDURE_OPD','procedure_opd.txt',ri,pid,cid,seq,'PID',pid,'','PID ไม่พบใน PERSON');
if (seq && !serviceSeqs.has(seq)) addError('medium','PROCEDURE_OPD','procedure_opd.txt',ri,pid,cid,seq,'SEQ',seq,'','SEQ ไม่พบใน SERVICE');
if (!proc) addError('medium','PROCEDURE_OPD','procedure_opd.txt',ri,pid,cid,seq,'PROCEDCODE','','','PROCEDCODE ว่างเปล่า');
if (date && !isValidDate8(date)) addError('medium','PROCEDURE_OPD','procedure_opd.txt',ri,pid,cid,seq,'DATE_SERV',date,'YYYYMMDD','รูปแบบวันที่ผิด');
});
}
// ========== DASHBOARD ==========
function calcScore() {
const total = state.errors.length;
if (total === 0) return 100;
const weights = { critical: 10, high: 5, medium: 2, low: 1 };
const severity_sum = state.errors.reduce((s,e) => s + (weights[e.sev]||0), 0);
const max_penalty = 500;
return Math.max(0, Math.round(100 - Math.min(severity_sum, max_penalty) / max_penalty * 100));
}
function renderDashboard() {
$('dashEmpty').style.display = 'none';
$('dashContent').style.display = 'block';
const score = calcScore();
const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
// Score ring
if (state.charts.score) state.charts.score.destroy();
state.charts.score = new Chart($('scoreChart'), {
type: 'doughnut',
data: { datasets: [{ data: [score, 100-score], backgroundColor: [scoreColor,'#e5e7eb'], borderWidth: 0 }] },
options: { cutout:'75%', plugins:{legend:{display:false}}, animation:{duration:800} }
});
$('scoreNum').textContent = score;
$('scoreNum').style.color = scoreColor;
const cnt = sev => state.errors.filter(e=>e.sev===sev).length;
const personFile = state.files['person.txt'];
const chronicFile = state.files['chronic.txt'];
$('dashKpi').innerHTML = `
<div class="kpi-card red"><div class="kpi-val red">${cnt('critical')}</div><div class="kpi-label">Critical Error</div></div>
<div class="kpi-card yellow"><div class="kpi-val yellow">${cnt('high')}</div><div class="kpi-label">High Error</div></div>
<div class="kpi-card blue"><div class="kpi-val">${cnt('medium')}</div><div class="kpi-label">Medium Error</div></div>
<div class="kpi-card green"><div class="kpi-val">${cnt('low')}</div><div class="kpi-label">Low / Info</div></div>
<div class="kpi-card purple"><div class="kpi-val">${fmtNum(personFile?.rows.length)}</div><div class="kpi-label">PERSON ทั้งหมด</div></div>
<div class="kpi-card green"><div class="kpi-val">${fmtNum(chronicFile?.rows.length)}</div><div class="kpi-label">CHRONIC</div></div>
<div class="kpi-card yellow"><div class="kpi-val">${state._noFuPids?.length ?? 0}</div><div class="kpi-label">CHRONIC ไม่มี FU</div></div>
<div class="kpi-card blue"><div class="kpi-val">${state._unmappedLabCodes?.size ?? 0}</div><div class="kpi-label">Lab Code ไม่ map</div></div>
`;
// Severity chart
if (state.charts.sev) state.charts.sev.destroy();
state.charts.sev = new Chart($('sevChart'), {
type: 'bar',
data: {
labels: ['Critical','High','Medium','Low'],
datasets: [{ data: [cnt('critical'),cnt('high'),cnt('medium'),cnt('low')],
backgroundColor: ['#dc2626','#f59e0b','#fef08a','#93c5fd'], borderRadius: 6 }]
},
options: { plugins:{legend:{display:false}}, scales:{y:{beginAtZero:true,grid:{color:'#e5e7eb'}}}, animation:{duration:600} }
});
// Module chart
const modules = ['PERSON','SERVICE','CHRONIC','CHRONICFU','LABFU','DIAGNOSIS_OPD','PROCEDURE_OPD'];
const modCounts = modules.map(m => state.errors.filter(e=>e.module===m).length);
if (state.charts.module) state.charts.module.destroy();
state.charts.module = new Chart($('moduleChart'), {
type: 'bar',
data: {
labels: modules,
datasets: [{ data: modCounts, backgroundColor: '#0d6e4d', borderRadius: 6 }]
},
options: { indexAxis:'y', plugins:{legend:{display:false}}, scales:{x:{beginAtZero:true,grid:{color:'#e5e7eb'}}}, animation:{duration:600} }
});
// TYPEAREA chart
const ta = state._personTypeareas || {};
if (state.charts.typearea) state.charts.typearea.destroy();
state.charts.typearea = new Chart($('typeareaChart'), {
type: 'pie',
data: {
labels: ['Type 1 ทะเบียนในเขต/อยู่จริง','Type 2 ทะเบียนในเขต/ตัวไม่อยู่','Type 3 อยู่จริง/ทะเบียนนอกเขต','Type 4 นอกเขตมารับบริการ','Type 5 อื่นๆ'],
datasets: [{ data: [ta[1]||0,ta[2]||0,ta[3]||0,ta[4]||0,ta[5]||0],
backgroundColor: ['#0d6e4d','#00b4d8','#f59e0b','#dc2626','#8b5cf6'] }]
},
options: { plugins:{legend:{position:'right'}}, animation:{duration:600} }
});
// Top 10 error types
const errGroups = {};
state.errors.forEach(e => {
const key = e.module + ' - ' + e.field;
errGroups[key] = (errGroups[key]||0) + 1;
});
const top10 = Object.entries(errGroups).sort((a,b)=>b[1]-a[1]).slice(0,10);
if (state.charts.top10) state.charts.top10.destroy();
state.charts.top10 = new Chart($('top10Chart'), {
type: 'bar',
data: {
labels: top10.map(([k])=>k.length>25?k.slice(0,25)+'…':k),
datasets: [{ data: top10.map(([,v])=>v), backgroundColor: '#f59e0b', borderRadius: 4 }]
},
options: { indexAxis:'y', plugins:{legend:{display:false}}, scales:{x:{beginAtZero:true}}, animation:{duration:600} }
});
// Alerts
const alerts = [];
if (cnt('critical') > 0) alerts.push(`<div class="alert alert-danger">🚨 พบ <strong>${cnt('critical')} Critical Error</strong> — ต้องแก้ไขก่อน Upload HDC</div>`);
if (state._noFuPids?.length > 0) alerts.push(`<div class="alert alert-warning">⚠️ CHRONIC ${state._noFuPids.length} รายไม่มี CHRONICFU — จะส่งผลต่อตัวชี้วัด NCD HDC</div>`);
if (state._candidatePids?.size > 0) alerts.push(`<div class="alert alert-info">💡 พบผู้ป่วย DM/HT ${state._candidatePids.size} รายที่ยังไม่ขึ้นทะเบียน CHRONIC</div>`);
if (score >= 85) alerts.push(`<div class="alert alert-success">✅ คะแนน HDC Readiness สูง (${score}/100) — พร้อม Upload</div>`);
$('dashAlerts').innerHTML = alerts.join('') || '<div class="alert alert-success">✅ ไม่พบ Alert สำคัญ</div>';
}
// ========== ERROR TABLE ==========
function renderErrorTable() {
const sev = $('filterSev').value;
const file = $('filterFile').value;
const module = $('filterModule').value;
const search = ($('filterSearch').value || '').toLowerCase();
const filtered = state.errors.filter(e => {
if (sev && e.sev !== sev) return false;
if (file && e.file !== file) return false;
if (module && e.module !== module) return false;
if (search && !`${e.pid}${e.cid}${e.seq}${e.desc}`.toLowerCase().includes(search)) return false;
return true;
}).sort((a,b) => severityOrder(a.sev) - severityOrder(b.sev));
$('errCount').textContent = fmtNum(filtered.length) + ' รายการ';
const sevBadge = s => `<span class="sev sev-${s}">${{critical:'Critical',high:'High',medium:'Medium',low:'Low',info:'Info'}[s]||s}</span>`;
$('errTableBody').innerHTML = filtered.slice(0,500).map((e,i) => {
const idx = state.errors.indexOf(e);
const canManualEdit = e.file && e.rowNum > 1 && e.field && state.files[e.file] && colIdx(e.file, e.field) >= 0;
return `
<tr>
<td class="mono">${i+1}</td>
<td>${sevBadge(e.sev)}</td>
<td><span style="font-weight:600;font-size:12px">${escapeHtml(e.module)}</span></td>
<td class="mono">${e.rowNum||'-'}</td>
<td class="mono">${escapeHtml(e.pid)||'-'}</td>
<td class="mono">${escapeHtml(e.cid)||'-'}</td>
<td class="mono">${escapeHtml(e.seq)||'-'}</td>
<td><strong>${escapeHtml(e.field)}</strong></td>
<td class="mono" style="color:var(--danger)">${escapeHtml(e.curVal)||'-'}</td>
<td class="mono" style="color:var(--success)">${escapeHtml(e.sugVal)||'-'}</td>
<td style="max-width:250px;font-size:12px">${escapeHtml(e.desc)}</td>
<td>
<div class="action-stack">
${e.action==='auto'?`<button class="btn btn-success btn-sm" onclick="applyFix(${idx})">Fix</button>`:''}
${canManualEdit?`<button class="btn btn-primary btn-sm" onclick="openManualEdit(${idx})">แก้ไข</button>`:''}
${e.module==='CHRONICFU' && e.field==='FU' && e.pid?`<button class="btn btn-warning btn-sm" onclick="openChronicFuEditor('${escapeHtml(e.pid)}')">เพิ่ม FU</button>`:''}
${e.action==='manual' && !canManualEdit?`<span class="sev sev-high" style="font-size:11px">Manual</span>`:''}
<button class="btn btn-outline btn-sm" onclick="ignoreError(${idx})">ข้าม</button>
</div>
</td>
</tr>`;
}).join('') + (filtered.length > 500 ? `<tr><td colspan="12" style="text-align:center;padding:12px;color:var(--text2);font-style:italic">แสดงเพียง 500 รายการแรก (ทั้งหมด ${fmtNum(filtered.length)} รายการ)</td></tr>` : '');
}
function ignoreError(idx) {
const e = state.errors[idx];
if (!e) return;
addLog('IGNORE', `ละเว้น error: ${e.module} ${e.field}`, e.curVal, '');
state.errors.splice(idx, 1);
renderErrorTable();
}
function openManualEdit(idx) {
const e = state.errors[idx];
if (!e || !state.files[e.file] || e.rowNum < 2) return;
const fieldOptions = state.files[e.file].header.map(h => `<option value="${escapeHtml(h)}" ${h===e.field?'selected':''}>${escapeHtml(h)}</option>`).join('');
const current = getVal(e.file, state.files[e.file].rows[e.rowNum - 2], e.field);
const body = `
<div class="alert alert-info">แก้ข้อมูลในไฟล์ ${escapeHtml(e.file)} row ${e.rowNum} แล้วระบบจะตรวจซ้ำและนำค่าใหม่ไปใช้ตอน Export ZIP</div>
<div class="edit-grid">
<label>File / Row</label><div class="mono">${escapeHtml(e.file)} / ${e.rowNum}</div>
<label>PID / CID / SEQ</label><div class="mono">${escapeHtml(e.pid)||'-'} / ${escapeHtml(e.cid)||'-'} / ${escapeHtml(e.seq)||'-'}</div>
<label>Field</label><select id="manualField" onchange="loadManualFieldValue(${idx})">${fieldOptions}</select>
<label>Current</label><input id="manualCurrent" class="mono" value="${escapeHtml(current)}" readonly>
<label>New value</label><input id="manualValue" class="mono" value="${escapeHtml(e.sugVal || current)}" autofocus>
</div>
<div class="edit-help">TYPEAREA: 1=ทะเบียนในเขตและอยู่จริง, 2=ทะเบียนในเขตแต่ตัวไม่อยู่, 3=อยู่จริงในเขตแต่ทะเบียนนอกเขต, 4=นอกเขตแต่มารับบริการ, 5=อื่นๆ; CID ต้องเป็นเลข 13 หลัก</div>`;
openModal('Manual Edit', body, `<button class="btn btn-primary btn-sm" onclick="saveManualEdit(${idx})">บันทึกและตรวจซ้ำ</button>`);
}
function loadManualFieldValue(idx) {
const e = state.errors[idx];
if (!e) return;
const field = $('manualField').value;
const current = getVal(e.file, state.files[e.file].rows[e.rowNum - 2], field);
$('manualCurrent').value = current;
$('manualValue').value = current;
}
function saveManualEdit(idx) {
const e = state.errors[idx];
if (!e) return;
const field = $('manualField').value;
const value = $('manualValue').value.trim();
const before = setCellValue(e.file, e.rowNum, field, value);
addLog('MANUAL_EDIT', `แก้ ${e.file} row ${e.rowNum} ${field}`, before, value);
closeModal();
refreshChecksAfterEdit('errors');
renderAuditLog();
}
function locateFixRow(e) {
if (!e) return 0;
if (e.rowNum > 1) return e.rowNum;
if (e.file === 'person.txt' && e.pid) return findRowNumByPid('person.txt', e.pid);
return 0;
}
function applyFix(idx) {
const e = state.errors[idx];
if (!e || !e.sugVal) return;
const rowNum = locateFixRow(e);
if (!rowNum || !state.files[e.file] || colIdx(e.file, e.field) < 0) {
if (e.module === 'CHRONICFU' && e.field === 'FU' && e.pid) openChronicFuEditor(e.pid);
else alert('รายการนี้ต้องแก้แบบ manual หรือเพิ่ม record ใหม่');
return;
}
const before = setCellValue(e.file, rowNum, e.field, e.sugVal);
addLog('AUTO_FIX', `Auto-fix: ${e.file} row ${rowNum} ${e.field}`, before, e.sugVal);
refreshChecksAfterEdit('errors');
renderAuditLog();
}
function autoFixSafe() {
const fixable = state.errors.filter(e => e.action === 'auto' && e.sugVal);
if (!fixable.length) { alert('ไม่มี error ที่สามารถ Auto-fix ได้อย่างปลอดภัย'); return; }
if (!confirm(`จะ Auto-fix ${fixable.length} รายการ ตรวจสอบ Preview ก่อนยืนยัน?\n\nรายการจะถูก log ใน Audit Log`)) return;
let fixedCount = 0;
fixable.forEach(e => {
const rowNum = locateFixRow(e);
if (!rowNum || !state.files[e.file] || colIdx(e.file, e.field) < 0) return;
const before = setCellValue(e.file, rowNum, e.field, e.sugVal);
addLog('AUTO_FIX', `Auto-fix: ${e.file} row ${rowNum} ${e.field}`, before, e.sugVal);
fixedCount++;
});
refreshChecksAfterEdit('errors');
renderAuditLog();
alert(`✅ Auto-fix เสร็จสิ้น ${fixedCount} รายการ\nตรวจสอบ Audit Log เพื่อดูรายละเอียด`);
}
function previewFixes() {
const fixable = state.errors.filter(e => e.action === 'auto' && e.sugVal);
if (!fixable.length) { openModal('Preview Fix', '<div class="alert alert-success">ไม่มีรายการที่ auto-fix ได้</div>'); return; }
const rows = fixable.slice(0,50).map(e => `
<div class="fix-row">
<span style="font-weight:600;min-width:120px;font-size:12px">${e.module} / ${e.field}</span>
<span class="fix-from">${e.curVal||'(ว่าง)'}</span>
<span style="color:var(--text2)">→</span>
<span class="fix-to">${e.sugVal}</span>
<span style="font-size:11px;color:var(--text2)">(row ${e.rowNum})</span>
</div>`).join('');
openModal('Preview Auto-fix ('+fixable.length+' รายการ)',
`<div class="fix-preview">${rows}${fixable.length>50?`<div style="text-align:center;padding:8px;color:var(--text2)">...และอีก ${fixable.length-50} รายการ</div>`:''}</div>`,
`<button class="btn btn-success btn-sm" onclick="autoFixSafe();closeModal()">✅ Apply ทั้งหมด</button>`
);
}
// ========== PERSON PAGE ==========
function renderPersonPage() {
const ta = state._personTypeareas || {};
const total = state._personTotal || 0;
const pct = n => total ? Math.round(n/total*100) : 0;
$('personStats').innerHTML = `
<div class="kpi-grid">
<div class="kpi-card blue"><div class="kpi-val">${fmtNum(total)}</div><div class="kpi-label">PERSON ทั้งหมด</div></div>
<div class="kpi-card red"><div class="kpi-val red">${state.errors.filter(e=>e.module==='PERSON'&&e.sev==='critical').length}</div><div class="kpi-label">Critical Error</div></div>
<div class="kpi-card yellow"><div class="kpi-val yellow">${state.errors.filter(e=>e.module==='PERSON'&&e.sev==='high').length}</div><div class="kpi-label">High Error</div></div>
<div class="kpi-card green"><div class="kpi-val">${fmtNum(state.files['death.txt']?.rows.length||0)}</div><div class="kpi-label">DEATH records</div></div>
</div>`;
const colors = ['#0d6e4d','#00b4d8','#f59e0b','#dc2626','#8b5cf6'];
const labels = ['1 ทะเบียนในเขต/อยู่จริง','2 ทะเบียนในเขต/ตัวไม่อยู่','3 อยู่จริง/ทะเบียนนอกเขต','4 นอกเขตมารับบริการ','5 อื่นๆ'];
$('typeareaDetail').innerHTML = `
<div class="typearea-bars">
${[1,2,3,4,5].map((t,i) => `
<div class="ta-bar">
<div class="ta-bar-val">${fmtNum(ta[t]||0)}</div>
<div class="ta-bar-inner" style="background:${colors[i]};height:${Math.max(8,pct(ta[t]||0))}px;margin-top:4px"></div>
<div class="ta-bar-label" style="color:${colors[i]}">${labels[i]}</div>
<div style="font-size:11px;color:var(--text2)">${pct(ta[t]||0)}%</div>
</div>`).join('')}
</div>
<div style="margin-top:12px">
${(ta[1]||0) < total*0.1 ? `<div class="alert alert-warning">⚠️ TYPEAREA 1 (ทะเบียนในเขตและอยู่จริง) น้อยมาก (${pct(ta[1]||0)}%) — ตรวจสอบ address/zone mapping</div>` : ''}
${(ta[4]||0) > total*0.8 ? `<div class="alert alert-danger">🚨 TYPEAREA 4 (นอกเขตแต่มารับบริการ) สูงมาก (${pct(ta[4]||0)}%) — ตรวจสอบสถานะประชากรก่อน Upload</div>` : ''}
${ta.other > 0 ? `<div class="alert alert-warning">⚠️ TYPEAREA ไม่ถูกต้อง: ${fmtNum(ta.other)} ราย</div>` : ''}
</div>`;
}
function applyTypeareaEdit() {
const pf = state.files['person.txt'];
if (!pf) { alert('ไม่พบ person.txt'); return; }
const pid = ($('taPid').value || '').trim();
const from = $('taFrom').value;
const to = $('taTo').value;
if (!['1','2','3','4','5'].includes(to)) { alert('TYPEAREA ปลายทางต้องเป็น 1-5'); return; }
const targets = [];
pf.rows.forEach((row, i) => {
const rowPid = getVal('person.txt', row, 'PID');
const current = getVal('person.txt', row, 'TYPEAREA');
if (pid && rowPid !== pid) return;
if (!pid && from && current !== from) return;
if (pid && from && current !== from) return;
targets.push({ rowNum: i + 2, pid: rowPid, before: current });
});
if (!targets.length) { alert('ไม่พบรายการ PERSON ที่ตรงเงื่อนไข'); return; }
const msg = pid
? `ยืนยันเปลี่ยน TYPEAREA ของ PID ${pid} เป็น ${to}?`
: `ยืนยันเปลี่ยน TYPEAREA ${from || 'ทุกค่า'} เป็น ${to} จำนวน ${targets.length} ราย?`;
if (!confirm(msg)) return;
targets.forEach(t => {
const before = setCellValue('person.txt', t.rowNum, 'TYPEAREA', to);
addLog('MANUAL_EDIT', `แก้ TYPEAREA person.txt row ${t.rowNum} PID ${t.pid}`, before, to);
});
refreshChecksAfterEdit('person');
renderAuditLog();
alert(`แก้ TYPEAREA สำเร็จ ${targets.length} ราย`);
}
// ========== CHRONIC PAGE ==========
function renderChronicPage() {
const cf = state.files['chronic.txt'];
const fuFile = state.files['chronicfu.txt'];
const chronicPids = state._chronicPids || new Set();
const fuPids = state._fuPids || new Set();
const noFuPids = state._noFuPids || [];
// Disease breakdown
const diseaseMap = {};
if (cf) {
cf.rows.forEach(row => {
const code = getVal('chronic.txt', row, 'CHRONIC');
const cat = code.startsWith('E1') ? 'DM' : code.startsWith('I1') ? 'HT' : code.startsWith('N18') ? 'CKD' : code.startsWith('J44') ? 'COPD' : code.startsWith('J45') ? 'ASTHMA' : code.startsWith('I6') ? 'STROKE' : code.startsWith('I2') ? 'IHD' : 'อื่นๆ';
diseaseMap[cat] = (diseaseMap[cat]||0) + 1;
});
}
$('chronicStats').innerHTML = `
<div class="kpi-grid">
<div class="kpi-card green"><div class="kpi-val">${fmtNum(chronicPids.size)}</div><div class="kpi-label">CHRONIC ทั้งหมด</div></div>
${Object.entries(diseaseMap).map(([cat,n]) => `<div class="kpi-card blue"><div class="kpi-val">${fmtNum(n)}</div><div class="kpi-label">${cat}</div></div>`).join('')}
</div>`;
$('chronicFuStats').innerHTML = `
<div class="kpi-grid">
<div class="kpi-card green"><div class="kpi-val">${fmtNum(fuFile?.rows.length||0)}</div><div class="kpi-label">CHRONICFU records</div></div>
<div class="kpi-card green"><div class="kpi-val">${fmtNum(fuPids.size)}</div><div class="kpi-label">ผู้ป่วยที่มี FU</div></div>
<div class="kpi-card red"><div class="kpi-val red">${fmtNum(noFuPids.length)}</div><div class="kpi-label">ไม่มี FU</div></div>
</div>`;
// No FU list
if (noFuPids.length > 0) {
const pf = state.files['person.txt'];
const personMap = {};
if (pf) pf.rows.forEach(r => { const pid = getVal('person.txt',r,'PID'); if(pid) personMap[pid] = r; });
$('chronicMissingFu').innerHTML = `
<div class="tbl-wrap" style="max-height:300px;overflow-y:auto">
<table>
<thead><tr><th>PID</th><th>CID</th><th>ชื่อ</th><th>โรค (CHRONIC code)</th><th>Action</th></tr></thead>
<tbody>
${noFuPids.slice(0,100).map(pid => {
const prow = personMap[pid];
const name = prow ? `${getVal('person.txt',prow,'NAME')} ${getVal('person.txt',prow,'LNAME')}` : '—';
const cid = prow ? getVal('person.txt',prow,'CID') : '—';
const diseases = cf ? [...new Set(cf.rows.filter(r=>getVal('chronic.txt',r,'PID')===pid).map(r=>getVal('chronic.txt',r,'CHRONIC')))].join(', ') : '—';
return `<tr><td class="mono">${escapeHtml(pid)}</td><td class="mono">${escapeHtml(cid)}</td><td>${escapeHtml(name)}</td><td>${escapeHtml(diseases)}</td><td><button class="btn btn-warning btn-sm" onclick="openChronicFuEditor('${escapeHtml(pid)}')">เพิ่ม FU</button></td></tr>`;
}).join('')}
${noFuPids.length > 100 ? `<tr><td colspan="5" style="text-align:center;color:var(--text2);font-style:italic">แสดงเพียง 100 รายการแรก (ทั้งหมด ${fmtNum(noFuPids.length)} ราย)</td></tr>` : ''}
</tbody>
</table>
</div>`;
} else {
$('chronicMissingFu').innerHTML = '<div class="alert alert-success">✅ ผู้ป่วย CHRONIC ทุกรายมี CHRONICFU</div>';
}
// Candidates
const candidates = state._candidatePids || new Set();
if (candidates.size > 0) {
const info = state._candidateChronicInfo || {};
$('chronicCandidates').innerHTML = `
<div class="alert alert-info">พบ <strong>${candidates.size} ราย</strong> ที่มี Diagnosis DM (E10-E14) หรือ HT (I10-I15) ใน DIAGNOSIS_OPD แต่ยังไม่ขึ้นทะเบียน CHRONIC</div>
<div class="btn-group" style="margin-bottom:10px">
<button class="btn btn-warning btn-sm" onclick="bulkRegisterChronicCandidates()">ขึ้นทะเบียนทั้งหมดตาม Diag ล่าสุด</button>
</div>
<div class="tbl-wrap" style="max-height:200px;overflow-y:auto">
<table><thead><tr><th>PID</th><th>CID</th><th>CHRONIC</th><th>DATE_DIAG</th><th>Action</th></tr></thead>
<tbody>${[...candidates].slice(0,100).map(pid=>`<tr><td class="mono">${escapeHtml(pid)}</td><td class="mono">${escapeHtml(info[pid]?.cid || personCidByPid(pid))}</td><td class="mono">${escapeHtml(info[pid]?.chronic || '')}</td><td class="mono">${escapeHtml(info[pid]?.dateServ || '')}</td><td><button class="btn btn-primary btn-sm" onclick="openChronicRegisterEditor('${escapeHtml(pid)}')">ขึ้น CHRONIC</button></td></tr>`).join('')}</tbody></table>
</div>`;
} else {
$('chronicCandidates').innerHTML = '<div class="alert alert-success">✅ ไม่พบผู้ป่วยที่ควรขึ้นทะเบียนเพิ่ม</div>';
}
}
function openChronicFuEditor(pid='') {
const cfHeader = ['HOSPCODE','PID','SEQ','DATE_SERV','WEIGHT','HEIGHT','WAIST_CM','SBP','DBP','FOOT','RETINA','PROVIDER','D_UPDATE','CHRONICFUPLACE','CID'];
const fu = ensureFile('chronicfu.txt', cfHeader);
const cid = personCidByPid(pid);
const has = col => colIdx('chronicfu.txt', col) >= 0;
const rowFor = (label, id, value='', type='text') => `
<label>${label}</label><input id="${id}" class="mono" type="${type}" value="${escapeHtml(value)}">`;
const optionalRows = [
has('CID') ? rowFor('CID', 'fuCID', cid) : '',
has('WEIGHT') ? rowFor('WEIGHT', 'fuWEIGHT') : '',
has('HEIGHT') ? rowFor('HEIGHT', 'fuHEIGHT') : '',
has('WAIST_CM') ? rowFor('WAIST_CM', 'fuWAIST_CM') : '',
has('SBP') ? rowFor('SBP', 'fuSBP') : '',
has('DBP') ? rowFor('DBP', 'fuDBP') : '',
has('FOOT') ? `<label>FOOT</label><select id="fuFOOT"><option value=""></option><option value="1">1 ปกติ</option><option value="2">2 ผิดปกติ</option><option value="3">3 ไม่ตรวจ</option></select>` : '',
has('RETINA') ? `<label>RETINA</label><select id="fuRETINA"><option value=""></option><option value="1">1 ปกติ</option><option value="2">2 ผิดปกติ</option><option value="3">3 ไม่ตรวจ</option></select>` : '',
has('SMOKING') ? `<label>SMOKING</label><select id="fuSMOKING"><option value=""></option><option value="1">1 ไม่สูบ</option><option value="2">2 สูบ</option><option value="3">3 เลิกสูบ</option><option value="9">9 ไม่ทราบ</option></select>` : '',
has('ALCOHOL') ? `<label>ALCOHOL</label><select id="fuALCOHOL"><option value=""></option><option value="1">1 ไม่ดื่ม</option><option value="2">2 ดื่ม</option><option value="3">3 เลิกดื่ม</option><option value="9">9 ไม่ทราบ</option></select>` : ''
].join('');
const body = `
<div class="alert alert-warning">เพิ่ม CHRONICFU 1 record ตามโครงสร้าง column ของไฟล์เดิม กรุณาใส่ SEQ ของ visit ที่ติดตามจริงจาก SERVICE และวันที่ DATE_SERV รูปแบบ YYYYMMDD</div>
<div class="btn-group" style="margin-bottom:12px">
<button class="btn btn-outline btn-sm" onclick="fillFuFromLatest()">ดึงค่าล่าสุดของ PID นี้</button>
<button class="btn btn-outline btn-sm" onclick="fillFuFromSeq()">ดึงจาก SERVICE ตาม SEQ</button>
<button class="btn btn-success btn-sm" onclick="fillFuNormalDefaults()">เติมค่าปกติ</button>
</div>
<div class="edit-grid">
${rowFor('HOSPCODE', 'fuHOSPCODE', state.hcode)}
${rowFor('PID', 'fuPID', pid)}
${rowFor('SEQ', 'fuSEQ')}
${rowFor('DATE_SERV', 'fuDATE_SERV', ymdToday())}
${optionalRows}
${has('PROVIDER') ? rowFor('PROVIDER', 'fuPROVIDER') : ''}
${has('D_UPDATE') ? rowFor('D_UPDATE', 'fuDUPDATE', ymdToday() + '000000') : ''}
${has('CHRONICFUPLACE') ? rowFor('CHRONICFUPLACE', 'fuCHRONICFUPLACE', state.hcode) : ''}
</div>
<div class="edit-help">ถ้าไม่มีการติดตามจริง ไม่ควรสร้างข้อมูลแทนผลบริการจริง ให้กลับไปบันทึก visit/FU ในระบบต้นทางแล้ว export 43 แฟ้มใหม่</div>`;
openModal('เพิ่ม CHRONICFU', body, `<button class="btn btn-primary btn-sm" onclick="saveChronicFuRecord()">เพิ่ม record และตรวจซ้ำ</button>`);
fillFuNormalDefaults();
if (latestRowByPid('chronicfu.txt', pid)) fillFuFromLatest();
}
function fuInput(id) {
const el = $(id);
return el ? el.value.trim() : '';
}
function saveChronicFuRecord() {
const pid = fuInput('fuPID');
const seq = fuInput('fuSEQ');
const dateServ = fuInput('fuDATE_SERV');
if (!pid) { alert('กรุณาระบุ PID'); return; }
if (!seq) { alert('กรุณาระบุ SEQ ของ visit ที่ติดตาม'); return; }
if (!isValidDate8(dateServ)) { alert('DATE_SERV ต้องเป็น YYYYMMDD'); return; }
const values = {
HOSPCODE: fuInput('fuHOSPCODE') || state.hcode,
PID: pid,
CID: fuInput('fuCID') || personCidByPid(pid),
SEQ: seq,
DATE_SERV: dateServ,
WEIGHT: fuInput('fuWEIGHT'),
HEIGHT: fuInput('fuHEIGHT'),
WAIST_CM: fuInput('fuWAIST_CM'),
SBP: fuInput('fuSBP'),
DBP: fuInput('fuDBP'),
FOOT: fuInput('fuFOOT'),
RETINA: fuInput('fuRETINA'),
SMOKING: fuInput('fuSMOKING'),
ALCOHOL: fuInput('fuALCOHOL'),
PROVIDER: fuInput('fuPROVIDER'),
D_UPDATE: fuInput('fuDUPDATE') || ymdToday() + '000000',
CHRONICFUPLACE: fuInput('fuCHRONICFUPLACE') || state.hcode
};
const rowNum = appendRow('chronicfu.txt', values);
addLog('MANUAL_EDIT', `เพิ่ม CHRONICFU row ${rowNum} PID ${pid} SEQ ${seq}`, '', JSON.stringify(values));
closeModal();
refreshChecksAfterEdit('chronic');
renderAuditLog();
}
function dmCandidateMap() {
const out = new Map();
const add = (pid, source, code, date='', cid='') => {
if (!pid || !isDmCode(code)) return;
const cur = out.get(pid);
if (!cur || date > cur.date) out.set(pid, { pid, source, code, date, cid: cid || personCidByPid(pid) });
};
const chronic = state.files['chronic.txt'];
if (chronic) chronic.rows.forEach(r => add(getVal('chronic.txt', r, 'PID'), 'CHRONIC', getVal('chronic.txt', r, 'CHRONIC'), getVal('chronic.txt', r, 'DATE_DIAG'), getVal('chronic.txt', r, 'CID')));
const dopd = state.files['diagnosis_opd.txt'];
if (dopd) dopd.rows.forEach(r => add(getVal('diagnosis_opd.txt', r, 'PID'), 'DIAGNOSIS_OPD', getVal('diagnosis_opd.txt', r, 'DIAGCODE'), getVal('diagnosis_opd.txt', r, 'DATE_SERV'), getVal('diagnosis_opd.txt', r, 'CID')));
const dipd = state.files['diagnosis_ipd.txt'];
if (dipd) dipd.rows.forEach(r => add(getVal('diagnosis_ipd.txt', r, 'PID'), 'DIAGNOSIS_IPD', getVal('diagnosis_ipd.txt', r, 'DIAGCODE'), getVal('diagnosis_ipd.txt', r, 'DATETIME_ADMIT').slice(0,8), getVal('diagnosis_ipd.txt', r, 'CID')));
return out;
}
function personEligibility(pid) {
const pf = state.files['person.txt'];
if (!pf) return { ok:false, reason:'ไม่มี PERSON' };
const rowNum = findRowNumByPid('person.txt', pid);
if (!rowNum) return { ok:false, reason:'PID ไม่พบใน PERSON' };
const row = pf.rows[rowNum - 2];
const typearea = getVal('person.txt', row, 'TYPEAREA');
const discharge = getVal('person.txt', row, 'DISCHARGE');
const nation = getVal('person.txt', row, 'NATION');
const reasons = [];
if (!['1','3'].includes(typearea)) reasons.push(`TYPEAREA=${typearea || 'ว่าง'} ไม่ใช่ 1/3`);
if (discharge !== '9') reasons.push(`DISCHARGE=${discharge || 'ว่าง'} ไม่ใช่ 9`);
if (nation !== '099') reasons.push(`NATION=${nation || 'ว่าง'} ไม่ใช่ 099`);
return { ok: reasons.length === 0, reason: reasons.join(', '), row, rowNum, typearea, discharge, nation, cid:getVal('person.txt', row, 'CID') };
}
function retinaRowsByPid(pid, fy) {
const cf = state.files['chronicfu.txt'];
if (!cf) return [];
const { start, end } = fiscalRange(fy);
return cf.rows
.map((row, i) => ({ row, rowNum:i+2, date:getVal('chronicfu.txt', row, 'DATE_SERV'), retina:getVal('chronicfu.txt', row, 'RETINA'), seq:getVal('chronicfu.txt', row, 'SEQ') }))
.filter(x => getVal('chronicfu.txt', x.row, 'PID') === String(pid) && x.date >= start && x.date <= end)
.sort((a,b) => b.date.localeCompare(a.date));
}
function buildRetinaAuditRows() {
const fy = Number($('retinaFy')?.value || currentThaiFiscalYear());
const dm = dmCandidateMap();
const rows = [];
dm.forEach((info, pid) => {
const person = personEligibility(pid);
const fus = retinaRowsByPid(pid, fy);
const retinaFu = fus.find(x => ['1','2','3','4'].includes(x.retina));
const abnormalFu = fus.find(x => ['1','2'].includes(x.retina));
let status = 'ไม่นับ';
let reason = '';
if (!person.ok) reason = person.reason;
else if (!fus.length) reason = 'ไม่มี CHRONICFU ในปีงบประมาณ';
else if (!retinaFu) reason = `มี CHRONICFU แต่ RETINA ไม่ใช่ 1-4 (${[...new Set(fus.map(x=>x.retina || 'ว่าง'))].join(', ')})`;
else status = 'นับตรวจตา';
rows.push({
pid, cid: person.cid || info.cid || '', name: person.row ? `${getVal('person.txt', person.row, 'NAME')} ${getVal('person.txt', person.row, 'LNAME')}` : '',
typearea: person.typearea || '', discharge: person.discharge || '', nation: person.nation || '',
chronic: info.code, source: info.source, dateDiag: info.date, fuCount: fus.length,
latestFuRow: fus[0]?.rowNum || '', latestSeq: fus[0]?.seq || '', latestDate: fus[0]?.date || '', retina: fus[0]?.retina || '',
counted: Boolean(person.ok && retinaFu), abnormal: Boolean(person.ok && abnormalFu), status, reason
});
});
return { fy, rows };
}
function renderRetinaAudit() {
if (!$('retinaAudit')) return;
if (!$('retinaFy').value) $('retinaFy').value = currentThaiFiscalYear();
const { fy, rows } = buildRetinaAuditRows();
state._retinaAuditRows = rows;
const eligible = rows.filter(r => !r.reason.startsWith('TYPEAREA') && !r.reason.includes('DISCHARGE') && !r.reason.includes('NATION') && r.typearea && ['1','3'].includes(r.typearea) && r.discharge === '9' && r.nation === '099');
const counted = rows.filter(r => r.counted);
const missingFu = rows.filter(r => r.reason === 'ไม่มี CHRONICFU ในปีงบประมาณ');
const badRetina = rows.filter(r => r.reason.startsWith('มี CHRONICFU แต่ RETINA'));
const notPerson = rows.filter(r => !r.typearea || !['1','3'].includes(r.typearea) || r.discharge !== '9' || r.nation !== '099');
const pct = eligible.length ? Math.round(counted.length / eligible.length * 1000) / 10 : 0;
const list = rows.filter(r => !r.counted).slice(0,120);
$('retinaAudit').innerHTML = `
<div class="alert alert-info">ตามเอกสาร HDC ปี 2567: ตรวจตาเบาหวานนับจาก CHRONICFU.RETINA = 1-4 ในปีงบประมาณ และกลุ่มเป้าหมายต้อง TYPEAREA 1/3, DISCHARGE 9, NATION 099</div>
<div class="kpi-grid">
<div class="kpi-card blue"><div class="kpi-val">${fmtNum(eligible.length)}</div><div class="kpi-label">DM เข้าเกณฑ์พื้นที่</div></div>
<div class="kpi-card green"><div class="kpi-val">${fmtNum(counted.length)}</div><div class="kpi-label">นับตรวจตา RETINA 1-4</div></div>
<div class="kpi-card yellow"><div class="kpi-val yellow">${pct}%</div><div class="kpi-label">Coverage FY ${fy}</div></div>
<div class="kpi-card red"><div class="kpi-val red">${fmtNum(missingFu.length)}</div><div class="kpi-label">ไม่มี CHRONICFU</div></div>
<div class="kpi-card red"><div class="kpi-val red">${fmtNum(badRetina.length)}</div><div class="kpi-label">RETINA ไม่ใช่ 1-4</div></div>
<div class="kpi-card yellow"><div class="kpi-val yellow">${fmtNum(notPerson.length)}</div><div class="kpi-label">ติด TYPEAREA/DISCHARGE/NATION</div></div>
</div>
<div class="tbl-wrap" style="max-height:320px;overflow-y:auto">
<table>
<thead><tr><th>PID</th><th>CID</th><th>ชื่อ</th><th>TYPEAREA</th><th>DM code</th><th>FU ล่าสุด</th><th>RETINA</th><th>สาเหตุที่ไม่ขึ้น</th><th>Action</th></tr></thead>
<tbody>${list.map(r => `
<tr>
<td class="mono">${escapeHtml(r.pid)}</td><td class="mono">${escapeHtml(r.cid)}</td><td>${escapeHtml(r.name)}</td>
<td class="mono">${escapeHtml(r.typearea)}</td><td class="mono">${escapeHtml(r.chronic)}</td>
<td class="mono">${escapeHtml(r.latestDate || '-')} ${r.latestSeq ? '('+escapeHtml(r.latestSeq)+')' : ''}</td>
<td class="mono">${escapeHtml(r.retina || '-')}</td><td>${escapeHtml(r.reason)}</td>
<td><div class="action-stack">
${r.latestFuRow ? `<button class="btn btn-primary btn-sm" onclick="openRetinaEditor('${escapeHtml(r.pid)}', ${r.latestFuRow})">แก้ RETINA</button>` : `<button class="btn btn-warning btn-sm" onclick="openRetinaEditor('${escapeHtml(r.pid)}', 0)">เพิ่ม FU/RETINA</button>`}
${(!['1','3'].includes(r.typearea) && r.typearea) ? `<button class="btn btn-outline btn-sm" onclick="quickSetTypearea('${escapeHtml(r.pid)}','1')">TYPEAREA=1</button>` : ''}
</div></td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
function quickSetTypearea(pid, value) {
const rowNum = findRowNumByPid('person.txt', pid);
if (!rowNum) return;
const before = setCellValue('person.txt', rowNum, 'TYPEAREA', value);
addLog('MANUAL_EDIT', `แก้ TYPEAREA จาก Retina audit PID ${pid}`, before, value);
refreshChecksAfterEdit('chronic');
renderAuditLog();
}
function openRetinaEditor(pid, rowNum=0) {
if (rowNum) {
const row = state.files['chronicfu.txt'].rows[rowNum - 2];
const current = getVal('chronicfu.txt', row, 'RETINA');
const body = `
<div class="alert alert-info">แก้ค่า RETINA ใน CHRONICFU row ${rowNum} ให้เป็นค่าที่ HDC นับได้ ถ้ามีการตรวจจริง</div>
<div class="edit-grid">
<label>PID / SEQ</label><div class="mono">${escapeHtml(pid)} / ${escapeHtml(getVal('chronicfu.txt', row, 'SEQ'))}</div>
<label>DATE_SERV</label><div class="mono">${escapeHtml(getVal('chronicfu.txt', row, 'DATE_SERV'))}</div>
<label>RETINA</label><select id="retinaValue"><option value="1">1 ปกติ</option><option value="2">2 ผิดปกติ</option><option value="3">3 ตรวจไม่ได้/ไม่ทราบผล</option><option value="4">4 ได้รับการตรวจ</option><option value="9">9 ไม่ตรวจ</option></select>
</div>`;
openModal('แก้ RETINA', body, `<button class="btn btn-primary btn-sm" onclick="saveRetinaValue(${rowNum})">บันทึก RETINA</button>`);
$('retinaValue').value = ['1','2','3','4','9'].includes(current) ? current : '1';
return;
}
openChronicFuEditor(pid);
setFuField('fuRETINA', '1');
}
function saveRetinaValue(rowNum) {
const val = $('retinaValue').value;
const before = setCellValue('chronicfu.txt', rowNum, 'RETINA', val);
addLog('MANUAL_EDIT', `แก้ CHRONICFU.RETINA row ${rowNum}`, before, val);
closeModal();
refreshChecksAfterEdit('chronic');
renderAuditLog();
}
function exportRetinaAuditExcel() {
const rows = state._retinaAuditRows || buildRetinaAuditRows().rows;
if (!rows.length) { alert('ไม่มีข้อมูล Retina audit'); return; }
const data = rows.map((r,i) => ({
'#': i+1, PID:r.pid, CID:r.cid, NAME:r.name, TYPEAREA:r.typearea, DISCHARGE:r.discharge, NATION:r.nation,
DM_CODE:r.chronic, SOURCE:r.source, DATE_DIAG:r.dateDiag, FU_COUNT:r.fuCount, LATEST_FU_ROW:r.latestFuRow,
LATEST_SEQ:r.latestSeq, LATEST_DATE:r.latestDate, RETINA:r.retina, COUNTED:r.counted?'Y':'N', ABNORMAL:r.abnormal?'Y':'N', REASON:r.reason
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Retina Audit');
XLSX.writeFile(wb, `RetinaAudit_${state.hcode}_${$('retinaFy').value || currentThaiFiscalYear()}.xlsx`);
addLog('EXPORT', `Export Retina Audit (${rows.length} ราย)`);
renderAuditLog();
}
function setFuField(id, value) {
const el = $(id);
if (el && value !== undefined && value !== null && value !== '') el.value = value;
}
function fillFuFromLatest() {
const pid = fuInput('fuPID');
const latest = latestRowByPid('chronicfu.txt', pid);
if (!latest) { alert('ไม่พบ CHRONICFU เดิมของ PID นี้'); return; }
['WEIGHT','HEIGHT','WAIST_CM','SBP','DBP','FOOT','RETINA','SMOKING','ALCOHOL','PROVIDER','CHRONICFUPLACE'].forEach(f => {
setFuField('fu' + f, getVal('chronicfu.txt', latest, f));
});
}
function fillFuNormalDefaults() {
setFuField('fuSBP', '120');
setFuField('fuDBP', '80');
setFuField('fuFOOT', '1');
setFuField('fuRETINA', '1');
setFuField('fuSMOKING', '1');
setFuField('fuALCOHOL', '1');
setFuField('fuCHRONICFUPLACE', state.hcode);
}
function fillFuFromSeq() {
const seq = fuInput('fuSEQ');
const svc = findServiceBySeq(seq);
if (!svc) { alert('ไม่พบ SEQ นี้ใน service.txt'); return; }
setFuField('fuPID', getVal('service.txt', svc, 'PID'));
setFuField('fuDATE_SERV', getVal('service.txt', svc, 'DATE_SERV'));
setFuField('fuCID', getVal('service.txt', svc, 'CID'));
setFuField('fuSBP', getVal('service.txt', svc, 'SBP'));
setFuField('fuDBP', getVal('service.txt', svc, 'DBP'));
}
function openChronicRegisterEditor(pid='') {
ensureFile('chronic.txt', ['HOSPCODE','PID','DATE_DIAG','CHRONIC','HOSP_DX','HOSP_RX','DATE_DISCH','TYPEDISCH','D_UPDATE','CID']);
const info = (state._candidateChronicInfo || {})[pid] || {};
const cid = info.cid || personCidByPid(pid);
const body = `
<div class="alert alert-warning">ขึ้นทะเบียน CHRONIC ควรทำเมื่อยืนยันแล้วว่าเป็นโรคเรื้อรังจริงตามเวชระเบียน ไม่ใช่เพียง diagnosis ชั่วคราว</div>
<div class="edit-grid">
<label>HOSPCODE</label><input id="chHOSPCODE" class="mono" value="${escapeHtml(state.hcode)}">
<label>PID</label><input id="chPID" class="mono" value="${escapeHtml(pid)}">
<label>CID</label><input id="chCID" class="mono" value="${escapeHtml(cid)}">
<label>DATE_DIAG</label><input id="chDATE_DIAG" class="mono" value="${escapeHtml(info.dateServ || ymdToday())}">
<label>CHRONIC</label><input id="chCHRONIC" class="mono" value="${escapeHtml(info.chronic || '')}">
<label>HOSP_DX</label><input id="chHOSP_DX" class="mono" value="${escapeHtml(state.hcode)}">
<label>HOSP_RX</label><input id="chHOSP_RX" class="mono" value="${escapeHtml(state.hcode)}">
<label>DATE_DISCH</label><input id="chDATE_DISCH" class="mono" value="">
<label>TYPEDISCH</label><input id="chTYPEDISCH" class="mono" value="">
<label>D_UPDATE</label><input id="chDUPDATE" class="mono" value="${ymdToday()}000000">
</div>`;
openModal('ขึ้นทะเบียน CHRONIC', body, `<button class="btn btn-primary btn-sm" onclick="saveChronicRegister()">เพิ่ม CHRONIC และตรวจซ้ำ</button>`);
}
function chInput(id) {
const el = $(id);
return el ? el.value.trim() : '';
}
function chronicValuesFromDialog() {
return {
HOSPCODE: chInput('chHOSPCODE') || state.hcode,
PID: chInput('chPID'),
DATE_DIAG: chInput('chDATE_DIAG'),
CHRONIC: chInput('chCHRONIC').toUpperCase(),
HOSP_DX: chInput('chHOSP_DX') || state.hcode,
HOSP_RX: chInput('chHOSP_RX') || state.hcode,
DATE_DISCH: chInput('chDATE_DISCH'),
TYPEDISCH: chInput('chTYPEDISCH'),
D_UPDATE: chInput('chDUPDATE') || ymdToday() + '000000',
CID: chInput('chCID') || personCidByPid(chInput('chPID'))
};
}
function saveChronicRegister() {
const values = chronicValuesFromDialog();
if (!values.PID) { alert('กรุณาระบุ PID'); return; }
if (!isValidDate8(values.DATE_DIAG)) { alert('DATE_DIAG ต้องเป็น YYYYMMDD'); return; }
if (!isICD10(values.CHRONIC)) { alert('CHRONIC ต้องเป็น ICD-10 เช่น E119 หรือ I10'); return; }
const exists = state.files['chronic.txt']?.rows.some(r => getVal('chronic.txt', r, 'PID') === values.PID && getVal('chronic.txt', r, 'CHRONIC') === values.CHRONIC);
if (exists) { alert('มี PID + CHRONIC นี้อยู่แล้ว'); return; }
const rowNum = appendRow('chronic.txt', values);
addLog('MANUAL_EDIT', `เพิ่ม CHRONIC row ${rowNum} PID ${values.PID} ${values.CHRONIC}`, '', JSON.stringify(values));
closeModal();
refreshChecksAfterEdit('chronic');
renderAuditLog();
}
function bulkRegisterChronicCandidates() {
const info = state._candidateChronicInfo || {};
const pids = [...(state._candidatePids || new Set())].filter(pid => info[pid]?.chronic);
if (!pids.length) { alert('ไม่พบ candidate ที่มีรหัส CHRONIC ชัดเจน'); return; }
if (!confirm(`ยืนยันเพิ่ม CHRONIC จาก diagnosis ล่าสุด จำนวน ${pids.length} ราย?\\nควรใช้เมื่อยืนยันทะเบียนโรคจริงแล้ว`)) return;
ensureFile('chronic.txt', ['HOSPCODE','PID','DATE_DIAG','CHRONIC','HOSP_DX','HOSP_RX','DATE_DISCH','TYPEDISCH','D_UPDATE','CID']);
let added = 0;
pids.forEach(pid => {
const item = info[pid];
const exists = state.files['chronic.txt'].rows.some(r => getVal('chronic.txt', r, 'PID') === pid && getVal('chronic.txt', r, 'CHRONIC') === item.chronic);
if (exists) return;
appendRow('chronic.txt', {
HOSPCODE: state.hcode, PID: pid, DATE_DIAG: item.dateServ || ymdToday(), CHRONIC: item.chronic,
HOSP_DX: state.hcode, HOSP_RX: state.hcode, DATE_DISCH: '', TYPEDISCH: '',
D_UPDATE: ymdToday() + '000000', CID: item.cid || personCidByPid(pid)
});
added++;
});
addLog('MANUAL_EDIT', `เพิ่ม CHRONIC candidate แบบชุด ${added} ราย`, '', '');
refreshChecksAfterEdit('chronic');
renderAuditLog();
alert(`เพิ่ม CHRONIC สำเร็จ ${added} ราย`);
}
// ========== LAB PAGE ==========
function renderLabPage() {
const lf = state.files['labfu.txt'];
const unmapped = state._unmappedLabCodes || new Map();
const outliers = state._labOutliers || [];
$('labStats').innerHTML = `
<div class="kpi-grid">
<div class="kpi-card blue"><div class="kpi-val">${fmtNum(lf?.rows.length||0)}</div><div class="kpi-label">LABFU records</div></div>
<div class="kpi-card green"><div class="kpi-val">${labMapping.length}</div><div class="kpi-label">Mapped Lab Codes</div></div>
<div class="kpi-card red"><div class="kpi-val red">${unmapped.size}</div><div class="kpi-label">ยังไม่ map</div></div>
<div class="kpi-card yellow"><div class="kpi-val yellow">${outliers.length}</div><div class="kpi-label">Outlier results</div></div>
</div>`;
$('labOutliers').innerHTML = outliers.length > 0 ?
`<div class="tbl-wrap" style="max-height:200px;overflow-y:auto">
<table><thead><tr><th>Row</th><th>PID</th><th>Lab</th><th>Result</th><th>Category</th></tr></thead>
<tbody>${outliers.slice(0,50).map(o=>`<tr><td class="mono">${o.ri}</td><td class="mono">${o.pid}</td><td class="mono">${o.labtest}</td><td class="mono" style="color:var(--danger);font-weight:700">${o.labresult}</td><td>${o.cat}</td></tr>`).join('')}</tbody>
</table></div>` :
'<div class="alert alert-success">✅ ไม่พบค่า Lab Outlier</div>';
if (unmapped.size > 0) {
const rows = [...unmapped.entries()].sort((a,b)=>b[1]-a[1]).map(([code,cnt]) =>
`<tr><td class="mono">${code}</td><td>${fmtNum(cnt)}</td>
<td><button class="btn btn-primary btn-sm" onclick="addMappingRowWithCode('${code}')">➕ เพิ่ม Mapping</button></td></tr>`
).join('');
$('labUnmapped').innerHTML = `<div class="tbl-wrap"><table><thead><tr><th>Lab Code</th><th>จำนวนครั้ง</th><th>Action</th></tr></thead><tbody>${rows}</tbody></table></div>`;
} else {
$('labUnmapped').innerHTML = '<div class="alert alert-success">✅ Lab Code ทุกรายการพบใน Mapping Table</div>';
}
}
// ========== MAPPING TABLE ==========
function renderMappingTable() {
$('mappingTableBody').innerHTML = labMapping.map((m, i) => `
<tr>
<td class="mono"><input type="text" value="${m.local}" onchange="labMapping[${i}].local=this.value" style="border:1px solid var(--border);padding:3px 6px;border-radius:4px;font-family:'IBM Plex Mono',monospace;font-size:12px;width:90px"></td>
<td><input type="text" value="${m.localName}" onchange="labMapping[${i}].localName=this.value" style="border:1px solid var(--border);padding:3px 6px;border-radius:4px;font-size:12px;width:160px"></td>
<td class="mono"><input type="text" value="${m.hdc}" onchange="labMapping[${i}].hdc=this.value" style="border:1px solid var(--border);padding:3px 6px;border-radius:4px;font-family:'IBM Plex Mono',monospace;font-size:12px;width:90px"></td>
<td>
<select onchange="labMapping[${i}].category=this.value" style="border:1px solid var(--border);padding:3px;border-radius:4px;font-size:12px">
${['HbA1c','FBS','LDL','Creatinine','eGFR','Microalbumin','Lipid','Urine','CBC','อื่นๆ'].map(c=>`<option ${m.category===c?'selected':''}>${c}</option>`).join('')}
</select>
</td>
<td><input type="text" value="${m.unit}" onchange="labMapping[${i}].unit=this.value" style="border:1px solid var(--border);padding:3px 6px;border-radius:4px;font-size:12px;width:80px"></td>
<td><span class="sev ${m.active?'sev-info':'sev-critical'}">${m.active?'Active':'Inactive'}</span></td>
<td><button class="btn btn-danger btn-sm" onclick="removeMappingRow(${i})">✕</button></td>
</tr>`).join('');
}
function addMappingRow() {
labMapping.push({ local:'', localName:'', hdc:'', category:'อื่นๆ', unit:'', active:true });
renderMappingTable();
addLog('MANUAL_EDIT', 'เพิ่ม Lab Mapping row ใหม่');
}
function addMappingRowWithCode(code) {
showPage('mapping');
labMapping.push({ local:code, localName:'', hdc:'', category:'อื่นๆ', unit:'', active:true });
renderMappingTable();
addLog('MANUAL_EDIT', `เพิ่ม Lab Mapping สำหรับ code: ${code}`);
}
function removeMappingRow(i) {
const removed = labMapping[i];
labMapping.splice(i, 1);
renderMappingTable();
addLog('MANUAL_EDIT', `ลบ Lab Mapping: ${removed.local} (${removed.localName})`);
}
function exportMapping() {
const ws = XLSX.utils.json_to_sheet(labMapping.map(m => ({
'Local Code': m.local, 'Local Name': m.localName, 'HDC Code': m.hdc,
'Category': m.category, 'Unit': m.unit, 'Active': m.active ? 'Y' : 'N'
})));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Lab Mapping');
XLSX.writeFile(wb, `LabMapping_${state.hcode}.xlsx`);
addLog('EXPORT', 'Export Lab Mapping Excel');
}
// ========== AUDIT LOG ==========
function renderAuditLog() {
const filter = $('logFilter').value;
const filtered = filter ? state.auditLog.filter(l => l.action === filter) : state.auditLog;
$('logCount').textContent = fmtNum(filtered.length) + ' รายการ';
const actionColors = { AUTO_FIX: 'var(--success)', MANUAL_EDIT: 'var(--accent)', IGNORE: 'var(--text2)', RESTORE: 'var(--warning)', UPLOAD: 'var(--primary)', CHECK: 'var(--primary)', EXPORT: '#8b5cf6' };
$('auditLogList').innerHTML = filtered.length ? [...filtered].reverse().map(l => `
<div class="log-entry">
<span class="log-time">${l.time}</span>
<span class="log-action" style="color:${actionColors[l.action]||'var(--text)'}">[${l.action}]</span>
<span class="log-detail">${l.detail}</span>
${l.before ? `<span class="mono" style="color:var(--danger);font-size:11px">${l.before}</span><span style="color:var(--text2)">→</span>` : ''}
${l.after ? `<span class="mono" style="color:var(--success);font-size:11px">${l.after}</span>` : ''}
</div>`).join('') : '<div class="empty-state" style="padding:30px"><div class="es-icon">📋</div><div>ยังไม่มีการดำเนินการ</div></div>';
}
// ========== EXPORT ==========
async function exportFixedZip() {
if (!Object.keys(state.files).length) { alert('ไม่มีข้อมูล'); return; }
loading(true, 'กำลัง Export ZIP...', '');
const zip = new JSZip();
const folder = zip.folder(`F43_${state.hcode}_FIXED`);
Object.entries(state.files).forEach(([name, f]) => {
touchFile(name);
folder.file(name, f.rawText, { binary: false });
});
const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = `F43_${state.hcode}_FIXED_${new Date().toISOString().slice(0,10).replace(/-/g,'')}.zip`;
a.click();
URL.revokeObjectURL(url);
addLog('EXPORT', 'Export ZIP 43 แฟ้มที่แก้ไขแล้ว');
loading(false);
renderAuditLog();
}
function exportErrorExcel() {
if (!state.errors.length) { alert('ไม่มี Error'); return; }
const data = state.errors.map((e,i) => ({
'#': i+1, 'Severity': e.sev, 'Module': e.module, 'File': e.file, 'Row': e.rowNum,
'PID': e.pid, 'CID': e.cid, 'SEQ': e.seq, 'Field': e.field,
'ค่าปัจจุบัน': e.curVal, 'ค่าที่แนะนำ': e.sugVal, 'คำอธิบาย': e.desc, 'Action': e.action
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Error Report');
XLSX.writeFile(wb, `HDC_Error_${state.hcode}_${new Date().toISOString().slice(0,10).replace(/-/g,'')}.xlsx`);
addLog('EXPORT', `Export Error Report Excel (${state.errors.length} รายการ)`);
renderAuditLog();
}
function exportAuditExcel() {
if (!state.auditLog.length) { alert('ไม่มีข้อมูล Audit Log'); return; }
const data = state.auditLog.map((l,i) => ({
'#': i+1, 'เวลา': l.time, 'Action': l.action, 'รายละเอียด': l.detail, 'ก่อน': l.before, 'หลัง': l.after
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Audit Log');
XLSX.writeFile(wb, `AuditLog_${state.hcode}_${new Date().toISOString().slice(0,10).replace(/-/g,'')}.xlsx`);
}
function resetApp() {
if (!confirm('รีเซ็ตข้อมูลทั้งหมด?')) return;
state = { files:{}, errors:[], auditLog:[], fixes:{}, originals:{}, hcode:'11422', checked:false, charts:{} };
$('fileSummarySection').style.display = 'none';
$('progressWrap').style.display = 'none';
$('topHcode').textContent = 'HCODE: —';
$('fileInput').value = '';
showPage('upload');
}
// ========== INIT ==========
renderMappingTable();
renderAuditLog();
</script>
</body>
</html>