| Server IP : 104.21.73.108 / 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>ระบบบริหารลูกหนี้ โรงพยาบาลป่าพะยอม</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Sarabun',Arial,sans-serif;background:#f0f4f8;color:#2d3748;font-size:14px}
.header{background:linear-gradient(135deg,#1a5276,#2980b9);color:#fff;padding:14px 24px;display:flex;align-items:center;gap:14px;box-shadow:0 2px 8px rgba(0,0,0,.25)}
.header-logo{width:52px;height:52px;background:#fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0}
.header-title{line-height:1.3}
.header-title h1{font-size:18px;font-weight:700}
.header-title p{font-size:12px;opacity:.85}
.nav{background:#fff;border-bottom:2px solid #e2e8f0;display:flex;padding:0 16px;overflow-x:auto}
.nav-tab{padding:12px 18px;cursor:pointer;font-size:13px;font-weight:600;color:#718096;border-bottom:3px solid transparent;white-space:nowrap;transition:all .2s}
.nav-tab.active{color:#1a5276;border-bottom-color:#2980b9}
.nav-tab:hover:not(.active){color:#2d3748;background:#f7fafc}
.content{padding:20px;max-width:1400px;margin:0 auto}
.panel{display:none}.panel.active{display:block}
/* Cards */
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;margin-bottom:20px}
.card{background:#fff;border-radius:10px;padding:16px 18px;box-shadow:0 1px 4px rgba(0,0,0,.08);border-left:4px solid #2980b9}
.card.green{border-left-color:#27ae60}.card.orange{border-left-color:#e67e22}.card.red{border-left-color:#e74c3c}.card.purple{border-left-color:#8e44ad}
.card-label{font-size:11px;color:#718096;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
.card-value{font-size:24px;font-weight:700;color:#2d3748}
.card-sub{font-size:11px;color:#a0aec0;margin-top:4px}
/* Tables */
.table-wrap{overflow-x:auto;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
table{width:100%;border-collapse:collapse;background:#fff}
thead th{background:#1a5276;color:#fff;padding:9px 12px;text-align:left;font-size:12px;white-space:nowrap}
thead th.num{text-align:right}
tbody tr:nth-child(even){background:#f7fafc}
tbody tr:hover{background:#ebf5fb}
tbody td{padding:7px 12px;font-size:12.5px;border-bottom:1px solid #e2e8f0;white-space:nowrap}
tbody td.num{text-align:right;font-variant-numeric:tabular-nums}
.tfoot td{background:#dbeafe;font-weight:700;padding:8px 12px;font-size:12.5px;border-top:2px solid #93c5fd}
.tfoot td.num{text-align:right}
/* Section */
.section{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.08);margin-bottom:20px;overflow:hidden}
.section-header{background:#1a5276;color:#fff;padding:10px 16px;font-size:13px;font-weight:700;display:flex;align-items:center;justify-content:space-between}
.section-body{padding:16px}
.section-header .badge{background:rgba(255,255,255,.25);border-radius:12px;padding:2px 10px;font-size:11px}
/* Upload area */
.upload-area{border:2px dashed #90cdf4;border-radius:10px;padding:28px;text-align:center;cursor:pointer;transition:all .2s;background:#ebf8ff}
.upload-area:hover{border-color:#2980b9;background:#dbeafe}
.upload-area input{display:none}
.upload-icon{font-size:36px;margin-bottom:8px}
.upload-title{font-weight:700;color:#1a5276;margin-bottom:4px}
.upload-sub{font-size:12px;color:#718096}
/* Status badges */
.badge-matched{background:#d1fae5;color:#065f46;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
.badge-partial{background:#fef3c7;color:#92400e;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
.badge-none{background:#fee2e2;color:#991b1b;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
.badge-deny{background:#ede9fe;color:#5b21b6;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
/* Charts row */
.charts-row{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px}
@media(max-width:768px){.charts-row{grid-template-columns:1fr}}
.chart-box{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.08);padding:16px}
.chart-box h3{font-size:13px;font-weight:700;color:#2d3748;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #e2e8f0}
/* Filters */
.filters{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;align-items:flex-end}
.filters label{font-size:12px;color:#4a5568;font-weight:600;display:block;margin-bottom:3px}
.filters select,.filters input{padding:6px 10px;border:1px solid #cbd5e0;border-radius:6px;font-size:13px;background:#fff;min-width:140px}
.btn{padding:7px 16px;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;transition:all .2s}
.btn-primary{background:#2980b9;color:#fff}.btn-primary:hover{background:#1a5276}
.btn-success{background:#27ae60;color:#fff}.btn-success:hover{background:#1e8449}
.btn-warning{background:#e67e22;color:#fff}.btn-warning:hover{background:#d35400}
.btn-sm{padding:4px 10px;font-size:12px}
.btn-outline{background:#fff;border:1px solid #cbd5e0;color:#4a5568}.btn-outline:hover{background:#f7fafc}
.btn-danger{background:#e74c3c;color:#fff;border:none}.btn-danger:hover{background:#c0392b}
.storage-tag{display:inline-block;font-size:11px;padding:3px 10px;border-radius:10px;background:rgba(255,255,255,.15);color:rgba(255,255,255,.8);white-space:nowrap}
.storage-tag.saved{background:rgba(39,174,96,.35);color:#a7f3d0}
.storage-tag.warn{background:rgba(231,76,60,.35);color:#fca5a5}
/* Import log */
.import-log{max-height:200px;overflow-y:auto;background:#1a202c;border-radius:8px;padding:12px;font-family:monospace;font-size:12px;color:#a0aec0}
.log-ok{color:#68d391}.log-warn{color:#f6ad55}.log-err{color:#fc8181}
/* Accounting report */
.report-page{background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:24px;max-width:900px;margin:0 auto;font-size:13px}
.report-title{text-align:center;font-weight:700;font-size:16px;margin-bottom:4px}
.report-sub{text-align:center;font-size:13px;color:#4a5568;margin-bottom:16px}
.report-line{display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px dotted #e2e8f0}
.report-section-title{background:#1a5276;color:#fff;padding:5px 10px;font-weight:700;margin:10px 0 6px;font-size:12px}
.report-total{display:flex;justify-content:space-between;padding:6px 0;font-weight:700;border-top:2px solid #2d3748}
/* Responsive */
@media(max-width:600px){.content{padding:10px}.cards{grid-template-columns:1fr 1fr}}
</style>
</head>
<body>
<div class="header">
<div class="header-logo">🏥</div>
<div class="header-title">
<h1>ระบบบริหารลูกหนี้ค่ารักษาพยาบาล</h1>
<p>โรงพยาบาลป่าพะยอม | รหัส 11422 | จังหวัดพัทลุง</p>
</div>
<div style="margin-left:auto;display:flex;gap:8px;align-items:center">
<span id="storage-tag" class="storage-tag">💾 ยังไม่มีข้อมูล</span>
<button class="btn btn-outline btn-sm" onclick="exportData()">💾 Export JSON</button>
<button class="btn btn-outline btn-sm" onclick="document.getElementById('importJsonFile').click()">📂 Import JSON</button>
<button class="btn btn-sm" style="background:rgba(231,76,60,.7);color:#fff;border:none;cursor:pointer" onclick="if(confirm('ล้างข้อมูลทั้งหมดออกจากเครื่อง (localStorage) ด้วย?')){store.impRecords=[];store.eclaimRecords=[];store.importLog=[];store.impFileCount=0;store.eclaimFileCount=0;localStorage.removeItem('hospital_ar_data_v2');refreshAll();log('🗑️ ล้างข้อมูลทั้งหมดแล้ว','warn');document.getElementById('storage-tag').textContent='💾 ยังไม่มีข้อมูล';document.getElementById('storage-tag').className='storage-tag';}">🗑️ ล้างทั้งหมด</button>
<input type="file" id="importJsonFile" accept=".json" style="display:none" onchange="importData(this)">
</div>
</div>
<nav class="nav">
<div class="nav-tab active" onclick="showTab('dashboard')">📊 แดชบอร์ด</div>
<div class="nav-tab" onclick="showTab('import')">📥 นำเข้าข้อมูล</div>
<div class="nav-tab" onclick="showTab('monthly')">📋 ตั้งลูกหนี้รายเดือน</div>
<div class="nav-tab" onclick="showTab('matching')">🔗 จับคู่ REP</div>
<div class="nav-tab" onclick="showTab('accounting')">📑 รายงานบัญชี</div>
</nav>
<div class="content">
<!-- ===== DASHBOARD ===== -->
<div id="panel-dashboard" class="panel active">
<div class="cards" id="dash-cards">
<div class="card"><div class="card-label">ลูกหนี้ทั้งหมด (ราย)</div><div class="card-value" id="d-total-count">0</div><div class="card-sub" id="d-total-amt">฿ 0</div></div>
<div class="card green"><div class="card-label">จับคู่ REP ครบแล้ว</div><div class="card-value" id="d-matched-count">0</div><div class="card-sub" id="d-matched-amt">฿ 0</div></div>
<div class="card orange"><div class="card-label">ค้างชำระ (มี REP บางส่วน)</div><div class="card-value" id="d-partial-count">0</div><div class="card-sub" id="d-partial-amt">฿ 0</div></div>
<div class="card red"><div class="card-label">ยังไม่มี REP</div><div class="card-value" id="d-none-count">0</div><div class="card-sub" id="d-none-amt">฿ 0</div></div>
<div class="card purple"><div class="card-label">ไฟล์ IMP ที่นำเข้า</div><div class="card-value" id="d-imp-files">0</div><div class="card-sub" id="d-eclaim-files">REP 0 ฉบับ</div></div>
</div>
<div class="charts-row">
<div class="chart-box">
<h3>📊 ยอดลูกหนี้แยกตามสิทธิ (บาท)</h3>
<canvas id="chart-by-right" height="200"></canvas>
</div>
<div class="chart-box">
<h3>📈 สถานะการจับคู่ REP</h3>
<canvas id="chart-status" height="200"></canvas>
</div>
</div>
<div class="section">
<div class="section-header">
📅 สรุปรายเดือน แยกตามสิทธิ
<span class="badge" id="dash-month-badge">0 เดือน</span>
</div>
<div class="section-body" style="padding:0">
<div class="table-wrap">
<table id="dash-monthly-table">
<thead><tr>
<th>เดือน</th><th>สิทธิ</th>
<th class="num">จำนวนราย</th>
<th class="num">ยอดตั้งหนี้ (บาท)</th>
<th class="num">ผู้ป่วยชำระ</th>
<th class="num">สิทธิชำระ</th>
<th class="num">ยอด REP รับได้</th>
<th class="num">ส่วนต่าง</th>
<th>สถานะ</th>
</tr></thead>
<tbody id="dash-monthly-body"><tr><td colspan="9" style="text-align:center;padding:24px;color:#a0aec0">ยังไม่มีข้อมูล — กรุณานำเข้าไฟล์ IMP ก่อน</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ===== IMPORT ===== -->
<div id="panel-import" class="panel">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px">
<div class="section">
<div class="section-header">📄 นำเข้าไฟล์ IMP (ตัวตั้งลูกหนี้)</div>
<div class="section-body">
<div class="upload-area" onclick="document.getElementById('impFiles').click()">
<input type="file" id="impFiles" accept=".xls,.xlsx" multiple onchange="handleImpUpload(this)">
<div class="upload-icon">📊</div>
<div class="upload-title">คลิกเพื่อเลือกไฟล์ IMP</div>
<div class="upload-sub">รองรับ .xls / .xlsx (หลายไฟล์พร้อมกัน)<br>เช่น IMP_66_IPD_04_รายงานผู้ป่วยใน.xls</div>
</div>
<div style="margin-top:12px;font-size:12px;color:#4a5568">
<b>คอลัมน์ที่ใช้:</b> HN, AN, หมายเลขบัตร, วันที่Admit/จำหน่าย, สิทธิการรักษา, ผู้ป่วยชำระ, สิทธิชำระ, ค่าใช้จ่ายทั้งหมด
</div>
<div id="imp-file-list" style="margin-top:10px"></div>
</div>
</div>
<div class="section">
<div class="section-header">📋 นำเข้าไฟล์ eclaim REP</div>
<div class="section-body">
<div class="upload-area" onclick="document.getElementById('eclaimFiles').click()">
<input type="file" id="eclaimFiles" accept=".xls,.xlsx" multiple onchange="handleEclaimUpload(this)">
<div class="upload-icon">🗂️</div>
<div class="upload-title">คลิกเพื่อเลือกไฟล์ eclaim REP</div>
<div class="upload-sub">รองรับ .xls / .xlsx (หลายไฟล์/งวด)<br>เช่น eclaim_11422_OP_25681231_xxx.xls</div>
</div>
<div style="margin-top:12px;font-size:12px;color:#4a5568">
<b>คอลัมน์ที่ใช้:</b> REP No., HN, AN, PID, ชดเชยสุทธิ, HC, AE, INST, IP, DMIS, DRUG
</div>
<div id="eclaim-file-list" style="margin-top:10px"></div>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
🖥️ ประวัติการนำเข้า
<button class="btn btn-sm" style="background:rgba(255,255,255,.2);color:#fff;border:none;cursor:pointer" onclick="clearLog()">ล้าง log</button>
</div>
<div class="section-body" style="padding:10px">
<div class="import-log" id="import-log"><span class="log-ok">พร้อมรับข้อมูล...</span></div>
</div>
</div>
<div class="section">
<div class="section-header">📊 สรุปข้อมูลที่นำเข้าแล้ว</div>
<div class="section-body">
<div class="cards" style="margin-bottom:0">
<div class="card"><div class="card-label">IMP Records</div><div class="card-value" id="imp-record-count">0</div></div>
<div class="card"><div class="card-label">eclaim Records</div><div class="card-value" id="eclaim-record-count">0</div></div>
<div class="card green"><div class="card-label">จำนวน REP No.</div><div class="card-value" id="rep-count">0</div></div>
<div class="card orange"><div class="card-label">เดือนข้อมูล</div><div class="card-value" id="month-count">0</div></div>
</div>
<div style="margin-top:12px;display:flex;gap:8px">
<button class="btn btn-warning" onclick="if(confirm('ล้างข้อมูล IMP ทั้งหมด?\n(ลบทุกรายการและประวัติไฟล์ทั้งหมด)')){store.impRecords=[];store.importLog=[];store.impFiles=[];store.impFileCount=0;refreshAll();log('ล้างข้อมูล IMP ทั้งหมดแล้ว','warn')}">🗑️ ล้างทั้งหมด IMP</button>
<button class="btn btn-warning" onclick="if(confirm('ล้างข้อมูล eclaim ทั้งหมด?\n(ลบทุกรายการและประวัติไฟล์ทั้งหมด)')){store.eclaimRecords=[];store.eclaimFiles=[];store.eclaimFileCount=0;refreshAll();log('ล้างข้อมูล eclaim ทั้งหมดแล้ว','warn')}">🗑️ ล้างทั้งหมด eclaim</button>
</div>
</div>
</div>
<!-- eclaim records table -->
<div class="section" style="margin-top:20px">
<div class="section-header">
📋 รายการ eclaim / REP ที่นำเข้าแล้ว
<span class="badge" id="eclaim-records-badge">0 รายการ</span>
</div>
<div class="section-body">
<div class="filters" style="margin-bottom:10px">
<div>
<label>ค้นหา HN / REP No / AN</label>
<input type="text" id="eclaim-search" placeholder="กรอก HN หรือ REP No..." oninput="renderEclaimTable()" style="min-width:200px">
</div>
<div>
<label>กองทุนหลัก</label>
<input type="text" id="eclaim-fund-filter" placeholder="เช่น HC16, AE04..." oninput="renderEclaimTable()" style="min-width:140px">
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>#</th>
<th>REP No.</th>
<th>HN</th><th>AN</th>
<th>วันที่บริการ</th>
<th>กองทุนหลัก</th>
<th>กองทุนย่อย</th>
<th class="num">ยอดชดเชย</th>
<th>ไฟล์ต้นทาง</th>
<th></th>
</tr></thead>
<tbody id="eclaim-records-body">
<tr><td colspan="10" style="text-align:center;padding:16px;color:#a0aec0">ยังไม่มีข้อมูล</td></tr>
</tbody>
</table>
</div>
<div id="eclaim-records-more" style="text-align:center;padding:8px;font-size:12px;color:#718096"></div>
</div>
</div>
</div>
<!-- ===== MONTHLY ===== -->
<div id="panel-monthly" class="panel">
<div class="filters">
<div>
<label>เดือน/ปี</label>
<select id="filter-month" onchange="renderMonthly()">
<option value="">-- ทุกเดือน --</option>
</select>
</div>
<div>
<label>สิทธิการรักษา</label>
<select id="filter-right" onchange="renderMonthly()">
<option value="">-- ทุกสิทธิ --</option>
</select>
</div>
<div>
<label>ประเภท</label>
<select id="filter-ptype" onchange="renderMonthly()">
<option value="">-- ทุกประเภท --</option>
<option value="IPD">IPD (ผู้ป่วยใน)</option>
<option value="OPD">OPD (ผู้ป่วยนอก)</option>
</select>
</div>
<div style="margin-top:18px">
<button class="btn btn-success" onclick="exportMonthlyExcel()">📥 Export Excel</button>
</div>
</div>
<div class="section">
<div class="section-header">
📋 สรุปรายเดือน แยกตามสิทธิ
<span class="badge" id="monthly-count-badge">0 รายการ</span>
</div>
<div class="section-body" style="padding:0">
<div class="table-wrap">
<table id="monthly-summary-table">
<thead><tr>
<th title="IPD=เดือนจำหน่าย OPD=เดือนรับบริการ">เดือน ℹ️</th>
<th>สิทธิการรักษา</th>
<th class="num">จำนวนราย</th>
<th class="num">ค่าใช้จ่ายทั้งหมด</th>
<th class="num">ผู้ป่วยชำระ</th>
<th class="num">สิทธิชำระ (ตั้งหนี้)</th>
<th class="num">ยอด REP รับ</th>
<th class="num">ส่วนต่าง</th>
<th class="num">% รับได้</th>
</tr></thead>
<tbody id="monthly-summary-body">
<tr><td colspan="9" style="text-align:center;padding:24px;color:#a0aec0">ยังไม่มีข้อมูล</td></tr>
</tbody>
<tfoot id="monthly-summary-foot"></tfoot>
</table>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
👤 รายชื่อผู้ป่วย
<span class="badge" id="patient-list-badge">0 ราย</span>
</div>
<div class="section-body" style="padding:0">
<div class="table-wrap">
<table id="patient-list-table">
<thead><tr>
<th>HN</th><th>AN</th><th>ชื่อ-สกุล</th>
<th>วันจำหน่าย</th><th>สิทธิการรักษา</th>
<th class="num">ผู้ป่วยชำระ</th>
<th class="num">สิทธิชำระ</th>
<th class="num">ค่าใช้จ่ายรวม</th>
<th class="num">ยอด REP</th>
<th>สถานะ</th>
<th></th>
</tr></thead>
<tbody id="patient-list-body">
<tr><td colspan="11" style="text-align:center;padding:24px;color:#a0aec0">ยังไม่มีข้อมูล</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ===== MATCHING ===== -->
<div id="panel-matching" class="panel">
<div class="filters">
<div>
<label>เดือน</label>
<select id="match-filter-month" onchange="renderMatching()">
<option value="">-- ทุกเดือน --</option>
</select>
</div>
<div>
<label>สถานะ</label>
<select id="match-filter-status" onchange="renderMatching()">
<option value="">-- ทุกสถานะ --</option>
<option value="matched">✅ จับคู่ได้ครบ</option>
<option value="partial">⚠️ บางส่วน</option>
<option value="none">❌ ไม่มี REP</option>
<option value="deny">🚫 DENY</option>
</select>
</div>
<div>
<label>สิทธิ</label>
<select id="match-filter-right" onchange="renderMatching()">
<option value="">-- ทุกสิทธิ --</option>
</select>
</div>
<div style="margin-top:18px">
<button class="btn btn-success" onclick="exportMatchingExcel()">📥 Export Excel</button>
</div>
</div>
<div class="cards" style="margin-bottom:16px">
<div class="card green"><div class="card-label">จับคู่ครบ</div><div class="card-value" id="m-matched">0</div></div>
<div class="card orange"><div class="card-label">บางส่วน</div><div class="card-value" id="m-partial">0</div></div>
<div class="card red"><div class="card-label">ไม่มี REP</div><div class="card-value" id="m-none">0</div></div>
<div class="card purple"><div class="card-label">DENY</div><div class="card-value" id="m-deny">0</div></div>
</div>
<div class="section">
<div class="section-header">🔗 รายละเอียดการจับคู่ REP พร้อมแยกกองทุน</div>
<div class="section-body" style="padding:0">
<div class="table-wrap">
<table id="matching-table">
<thead><tr>
<th>HN</th><th>ชื่อ-สกุล</th><th>วันจำหน่าย</th>
<th>สิทธิ</th><th>REP No.</th>
<th class="num">สิทธิชำระ</th>
<th class="num">ชดเชยสุทธิ</th>
<th class="num">HC</th>
<th class="num">AE</th>
<th class="num">INST</th>
<th class="num">IP/OP</th>
<th class="num">DMIS</th>
<th class="num">DRUG</th>
<th class="num">ส่วนต่าง</th>
<th>สถานะ</th>
</tr></thead>
<tbody id="matching-body">
<tr><td colspan="15" style="text-align:center;padding:24px;color:#a0aec0">ยังไม่มีข้อมูล</td></tr>
</tbody>
<tfoot id="matching-foot"></tfoot>
</table>
</div>
</div>
</div>
<div class="section">
<div class="section-header">📊 สรุปกองทุน แยกตามสิทธิ</div>
<div class="section-body" style="padding:0">
<div class="table-wrap">
<table id="fund-summary-table">
<thead><tr>
<th>สิทธิ</th>
<th class="num">ราย</th>
<th class="num">ตั้งหนี้ (สิทธิชำระ)</th>
<th class="num">ชดเชยสุทธิ</th>
<th class="num">HC ค่าใช้จ่ายสูง</th>
<th class="num">AE ฉุกเฉิน</th>
<th class="num">INST อวัยวะ</th>
<th class="num">IP ผู้ป่วยใน</th>
<th class="num">DMIS โรคเฉพาะ</th>
<th class="num">DRUG ยา</th>
<th class="num">ส่วนต่าง</th>
</tr></thead>
<tbody id="fund-summary-body"></tbody>
</table>
</div>
</div>
</div>
<div class="section">
<div class="section-header">🗂️ สรุปกองทุนหลัก (รหัสกองทุน) — แยกตามสิทธิ</div>
<div class="section-body" style="padding:0">
<div class="table-wrap">
<table id="fund-code-table">
<thead><tr>
<th>สิทธิ</th>
<th>รหัสกองทุนหลัก</th>
<th>หมวดกองทุน</th>
<th>กองทุนย่อย (Sub Fund)</th>
<th class="num">ราย</th>
<th class="num">ยอดชดเชย (บาท)</th>
</tr></thead>
<tbody id="fund-code-body"></tbody>
<tfoot id="fund-code-foot"></tfoot>
</table>
</div>
</div>
</div>
</div>
<!-- ===== ACCOUNTING ===== -->
<div id="panel-accounting" class="panel">
<div class="filters">
<div>
<label>เดือน/ปี</label>
<select id="acc-month" onchange="renderAccounting()">
<option value="">-- เลือกเดือน --</option>
</select>
</div>
<div>
<label>สิทธิ</label>
<select id="acc-right" onchange="renderAccounting()">
<option value="">-- ทุกสิทธิ --</option>
</select>
</div>
<div style="margin-top:18px;display:flex;gap:8px">
<button class="btn btn-primary" onclick="renderAccounting()">🔄 แสดงรายงาน</button>
<button class="btn btn-success" onclick="printAccounting()">🖨️ พิมพ์/PDF</button>
</div>
</div>
<div id="accounting-report"></div>
</div>
</div><!-- end content -->
<script>
// =============================================
// DATA STORE
// =============================================
const store = {
impRecords: [],
eclaimRecords: [],
importLog: [],
impFileCount: 0,
eclaimFileCount: 0,
impFiles: [], // [{name, added, total, date}] ไฟล์ IMP ที่นำเข้าแล้ว
eclaimFiles: [], // [{name, added, total, date}] ไฟล์ eclaim ที่นำเข้าแล้ว
};
// =============================================
// UTILITIES
// =============================================
function fmt(n) {
if (n === null || n === undefined || n === '' || n === '-') return '-';
const num = parseFloat(n);
if (isNaN(num)) return '-';
return num.toLocaleString('th-TH', {minimumFractionDigits:2, maximumFractionDigits:2});
}
function fmtInt(n) {
if (!n) return '0';
return parseInt(n).toLocaleString('th-TH');
}
function log(msg, type='ok') {
const el = document.getElementById('import-log');
const cls = type === 'ok' ? 'log-ok' : type === 'warn' ? 'log-warn' : 'log-err';
const ts = new Date().toLocaleTimeString('th-TH');
el.innerHTML += `\n<span class="${cls}">[${ts}] ${msg}</span>`;
el.scrollTop = el.scrollHeight;
}
function clearLog() {
document.getElementById('import-log').innerHTML = '<span class="log-ok">Log ถูกล้างแล้ว</span>';
}
function parseDateStr(s) {
if (!s) return null;
s = String(s).trim();
// Thai Buddhist Era: DD/MM/YYYY where YYYY might be BE (2568 = 2025 CE)
let m = s.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/);
if (m) {
let y = parseInt(m[3]);
if (y > 2400) y -= 543; // Convert BE to CE
return new Date(y, parseInt(m[2]) - 1, parseInt(m[1]));
}
// ISO date or Excel serial
let d = new Date(s);
if (!isNaN(d)) return d;
return null;
}
function monthKey(d) {
if (!d) return 'ไม่ระบุ';
return `${d.getFullYear() + 543}-${String(d.getMonth() + 1).padStart(2,'0')}`;
}
function monthLabel(key) {
if (key === 'ไม่ระบุ') return 'ไม่ระบุ';
const [y, m] = key.split('-');
const months = ['','ม.ค.','ก.พ.','มี.ค.','เม.ย.','พ.ค.','มิ.ย.','ก.ค.','ส.ค.','ก.ย.','ต.ค.','พ.ย.','ธ.ค.'];
return `${months[parseInt(m)]} ${y}`;
}
function normalizeRight(s) {
if (!s) return 'อื่นๆ';
s = String(s).trim();
if (s.includes('ถ้วนหน้า') || s.includes('หลักประกัน') || s.includes('UC') || s.includes('30 บาท')) return 'UC (บัตรทอง)';
if (s.includes('OFC')) return 'OFC';
if (s.includes('ต้นสังกัด') || s.includes('ราชการ') || s.includes('ข้าราชการ')) return 'ข้าราชการ/รัฐวิสาหกิจ';
if (s.includes('ประกันสังคม') || s.includes('SSS')) return 'ประกันสังคม';
if (s.includes('อุบัติเหตุ') || s.includes('ฉุกเฉิน') || s.includes('AE')) return 'อุบัติเหตุ/ฉุกเฉิน';
if (s.includes('เอกชน') || s.includes('ชีวิต')) return 'เอกชน/ประกันชีวิต';
if (s.includes('ต่างด้าว') || s.includes('แรงงาน')) return 'แรงงานต่างด้าว';
if (s.includes('สถานะ') || s.includes('UNK') || s.includes('ไม่มีสิทธิ') || s.includes('ปัญหาสถานะ')) return 'บุคคลมีปัญหาสถานะ';
if (s.includes('พรบ') || s.includes('ประกันรถ') || s.includes('รถยนต์')) return 'พรบ.รถ';
if (s.includes('อปท') || s.includes('ท้องถิ่น')) return 'อปท.';
if (s.includes('ชำระเงิน') || s.includes('เงินสด') || s.includes('จ่ายเอง')) return 'ชำระเงินเอง';
return s.length > 40 ? s.substring(0,40) + '...' : s;
}
// =============================================
// ACCOUNT CODE MAPPING — ผังบัญชีภาคสุขภาพ สำนักงานปลัดกระทรวงสาธารณสุข พ.ศ. 2560
// โครงสร้าง: { code, name } ต่อ rightNorm → ptype → subType
// =============================================
const ACCOUNT_MAP = {
// ─── UC / บัตรทอง (สปสช.) ──────────────────────────────
'UC (บัตรทอง)': {
OPD: {
default: { code:'1102050101.201', name:'ลูกหนี้ค่ารักษา UC-OP ใน CUP' },
nout_in: { code:'1102050101.203', name:'ลูกหนี้ค่ารักษา UC-OP นอก CUP (ในจังหวัดสังกัด สธ.)' },
nout_out: { code:'1102050101.204', name:'ลูกหนี้ค่ารักษา UC-OP นอก CUP (ต่างจังหวัดสังกัด สธ.)' },
pp: { code:'1102050101.209', name:'ลูกหนี้ค่ารักษาด้านการสร้างเสริมสุขภาพและป้องกันโรค (P&P)' },
cr: { code:'1102050101.216', name:'ลูกหนี้ค่ารักษา UC-OP บริการเฉพาะ (CR)' },
refer: { code:'1102050101.222', name:'ลูกหนี้ค่ารักษา OP-Refer' },
// HC/AE/DMI ใน REP map → CR (บริการเฉพาะ)
hc: { code:'1102050101.216', name:'ลูกหนี้ค่ารักษา UC-OP บริการเฉพาะ (CR) - HC' },
ae: { code:'1102050101.216', name:'ลูกหนี้ค่ารักษา UC-OP บริการเฉพาะ (CR) - AE' },
dmis: { code:'1102050101.216', name:'ลูกหนี้ค่ารักษา UC-OP บริการเฉพาะ (CR) - DMI' },
drug: { code:'1102050101.201', name:'ลูกหนี้ค่ายา UC-OP' },
inst: { code:'1102050101.201', name:'ลูกหนี้ค่าอวัยวะเทียม UC-OP' },
},
IPD: {
default: { code:'1102050101.202', name:'ลูกหนี้ค่ารักษา UC-IP' },
cr: { code:'1102050101.217', name:'ลูกหนี้ค่ารักษา UC-IP บริการเฉพาะ (CR)' },
hc: { code:'1102050101.217', name:'ลูกหนี้ค่ารักษา UC-IP บริการเฉพาะ (CR) - HC' },
ae: { code:'1102050101.217', name:'ลูกหนี้ค่ารักษา UC-IP บริการเฉพาะ (CR) - AE' },
dmis: { code:'1102050101.217', name:'ลูกหนี้ค่ารักษา UC-IP บริการเฉพาะ (CR) - DMI' },
drug: { code:'1102050101.202', name:'ลูกหนี้ค่ายา UC-IP' },
inst: { code:'1102050101.202', name:'ลูกหนี้ค่าอวัยวะเทียม UC-IP' },
},
},
// ─── อุบัติเหตุ/ฉุกเฉิน (ระบบปฏิบัติการ) ─────────────
'อุบัติเหตุ/ฉุกเฉิน': {
OPD: { default: { code:'1102050101.109', name:'ลูกหนี้-ระบบปฏิบัติการฉุกเฉิน' } },
IPD: { default: { code:'1102050101.109', name:'ลูกหนี้-ระบบปฏิบัติการฉุกเฉิน' } },
},
// ─── ประกันสังคม (SSO) ──────────────────────────────────
'ประกันสังคม': {
OPD: {
default: { code:'1102050101.301', name:'ลูกหนี้ค่ารักษาประกันสังคม OP-เครือข่าย' },
nout_sdhz: { code:'1102050101.303', name:'ลูกหนี้ค่ารักษาประกันสังคม OP-นอกเครือข่าย สังกัด สป.สธ.' },
replace: { code:'1102050101.307', name:'ลูกหนี้ค่ารักษาประกันสังคม-กองทุนทดแทน' },
e72: { code:'1102050101.308', name:'ลูกหนี้ค่ารักษาประกันสังคม 72 ชั่วโมงแรก' },
hc_ae: { code:'1102050101.309', name:'ลูกหนี้ค่ารักษาประกันสังคม-ค่าใช้จ่ายสูง/AE OP' },
hc: { code:'1102050101.309', name:'ลูกหนี้ค่ารักษาประกันสังคม-ค่าใช้จ่ายสูง/AE OP' },
ae: { code:'1102050101.309', name:'ลูกหนี้ค่ารักษาประกันสังคม-ค่าใช้จ่ายสูง/AE OP' },
nout_diff: { code:'1102050102.301', name:'ลูกหนี้ค่ารักษาประกันสังคม OP-นอกเครือข่าย ต่างสังกัด สป.สธ.' },
},
IPD: {
default: { code:'1102050101.302', name:'ลูกหนี้ค่ารักษาประกันสังคม IP-เครือข่าย' },
nout_sdhz: { code:'1102050101.304', name:'ลูกหนี้ค่ารักษาประกันสังคม IP-นอกเครือข่าย สังกัด สป.สธ.' },
hc: { code:'1102050101.310', name:'ลูกหนี้ค่ารักษาประกันสังคม-ค่าใช้จ่ายสูง IP' },
ae: { code:'1102050101.310', name:'ลูกหนี้ค่ารักษาประกันสังคม-ค่าใช้จ่ายสูง IP' },
nout_diff: { code:'1102050102.302', name:'ลูกหนี้ค่ารักษาประกันสังคม IP-นอกเครือข่าย ต่างสังกัด สป.สธ.' },
},
},
// ─── OFC / ข้าราชการ-เบิกตรงกรมบัญชีกลาง ───────────────
'OFC': {
OPD: { default: { code:'1102050101.401', name:'ลูกหนี้ค่ารักษา-เบิกจ่ายตรงกรมบัญชีกลาง OP' } },
IPD: { default: { code:'1102050101.402', name:'ลูกหนี้ค่ารักษา-เบิกจ่ายตรงกรมบัญชีกลาง IP' } },
},
'ข้าราชการ/รัฐวิสาหกิจ': {
OPD: {
default: { code:'1102050101.401', name:'ลูกหนี้ค่ารักษา-เบิกจ่ายตรงกรมบัญชีกลาง OP' },
tonsangkad: { code:'1102050102.108', name:'ลูกหนี้ค่ารักษา-เบิกต้นสังกัด OP' },
other: { code:'1102050102.110', name:'ลูกหนี้ค่ารักษา-เบิกจ่ายตรงหน่วยงานอื่น OP' },
},
IPD: {
default: { code:'1102050101.402', name:'ลูกหนี้ค่ารักษา-เบิกจ่ายตรงกรมบัญชีกลาง IP' },
tonsangkad: { code:'1102050102.109', name:'ลูกหนี้ค่ารักษา-เบิกต้นสังกัด IP' },
other: { code:'1102050102.111', name:'ลูกหนี้ค่ารักษา-เบิกจ่ายตรงหน่วยงานอื่น IP' },
},
},
// ─── คนต่างด้าว / แรงงานต่างด้าว ────────────────────────
'แรงงานต่างด้าว': {
OPD: {
default: { code:'1102050101.501', name:'ลูกหนี้ค่ารักษา-คนต่างด้าวและแรงงานต่างด้าว OP' },
nout_cup: { code:'1102050101.503', name:'ลูกหนี้ค่ารักษา-คนต่างด้าวและแรงงานต่างด้าว OP นอก CUP' },
central: { code:'1102050101.505', name:'ลูกหนี้ค่ารักษา-แรงงานต่างด้าว เบิกจากส่วนกลาง OP' },
},
IPD: {
default: { code:'1102050101.502', name:'ลูกหนี้ค่ารักษา-คนต่างด้าวและแรงงานต่างด้าว IP' },
nout_cup: { code:'1102050101.504', name:'ลูกหนี้ค่ารักษา-คนต่างด้าวและแรงงานต่างด้าว IP นอก CUP' },
central: { code:'1102050101.506', name:'ลูกหนี้ค่ารักษา-แรงงานต่างด้าว เบิกจากส่วนกลาง IP' },
},
},
// ─── บุคคลมีปัญหาสถานะและสิทธิ ─────────────────────────
'บุคคลมีปัญหาสถานะ': {
OPD: {
default: { code:'1102050101.701', name:'ลูกหนี้ค่ารักษา-บุคคลมีปัญหาสถานะและสิทธิ OP ใน CUP' },
nout_cup: { code:'1102050101.702', name:'ลูกหนี้ค่ารักษา-บุคคลมีปัญหาสถานะและสิทธิ OP นอก CUP' },
central: { code:'1102050101.703', name:'ลูกหนี้ค่ารักษาบุคคลมีปัญหาสถานะ-เบิกจากส่วนกลาง OP' },
},
IPD: {
default: { code:'1102050101.704', name:'ลูกหนี้ค่ารักษาบุคคลมีปัญหาสถานะ-เบิกจากส่วนกลาง IP' },
},
},
// ─── พรบ.รถ ─────────────────────────────────────────────
'พรบ.รถ': {
OPD: { default: { code:'1102050102.602', name:'ลูกหนี้ค่ารักษา-พรบ.รถ OP' } },
IPD: { default: { code:'1102050102.603', name:'ลูกหนี้ค่ารักษา-พรบ.รถ IP' } },
},
// ─── อปท. ────────────────────────────────────────────────
'อปท.': {
OPD: {
default: { code:'1102050102.801', name:'ลูกหนี้ค่ารักษา-เบิกจ่ายตรง อปท. OP' },
special: { code:'1102050102.803', name:'ลูกหนี้ค่ารักษา-เบิกจ่ายตรง อปท.รูปแบบพิเศษ OP' },
},
IPD: {
default: { code:'1102050102.802', name:'ลูกหนี้ค่ารักษา-เบิกจ่ายตรง อปท. IP' },
special: { code:'1102050102.804', name:'ลูกหนี้ค่ารักษา-เบิกจ่ายตรงอปท.รูปแบบพิเศษ IP' },
},
},
// ─── ชำระเงินเอง / บุคคลภายนอก ──────────────────────────
'ชำระเงินเอง': {
OPD: { default: { code:'1102050102.106', name:'ลูกหนี้ค่ารักษา-ชำระเงิน OP' } },
IPD: { default: { code:'1102050102.107', name:'ลูกหนี้ค่ารักษา-ชำระเงิน IP' } },
},
// ─── เอกชน / ประกันชีวิต ─────────────────────────────────
'เอกชน/ประกันชีวิต': {
OPD: { default: { code:'1102060000.000', name:'ลูกหนี้ค่ารักษา-เอกชน/ประกันชีวิต OP' } },
IPD: { default: { code:'1102060000.000', name:'ลูกหนี้ค่ารักษา-เอกชน/ประกันชีวิต IP' } },
},
};
function getAccountCode(rightNorm, ptype, subType) {
const pt = (ptype === 'IPD' || ptype === 'IP') ? 'IPD' : 'OPD';
const rm = ACCOUNT_MAP[rightNorm];
if (!rm) return { code:'1102050000.000', name:'ลูกหนี้ค่ารักษาพยาบาล ' + (rightNorm||'อื่นๆ') };
const pm = rm[pt];
if (!pm) return { code:'1102050000.000', name:'ลูกหนี้ค่ารักษาพยาบาล ' + rightNorm };
if (subType && pm[subType]) return pm[subType];
return pm.default || { code:'1102050000.000', name:'ลูกหนี้ค่ารักษาพยาบาล ' + rightNorm };
}
function toNum(v) {
if (v === null || v === undefined || v === '' || v === '-') return 0;
const n = parseFloat(String(v).replace(/,/g,''));
return isNaN(n) ? 0 : n;
}
// =============================================
// IMP PARSER
// =============================================
function parseImpFile(workbook, filename) {
const sheetName = workbook.SheetNames[0];
const ws = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json(ws, {header:1, defval:''});
// Find header row (contains 'HN')
let headerRow = 0;
for (let i = 0; i < Math.min(10, rows.length); i++) {
if (rows[i].some(c => String(c).trim() === 'HN')) { headerRow = i; break; }
}
const header = rows[headerRow].map(c => String(c).trim());
// Try column names in priority order
const colFirst = (...names) => {
for (const name of names) {
const i = header.findIndex(h => h.includes(name));
if (i >= 0) return i;
}
return -1;
};
// Map columns by name patterns (with OPD/IPD column name variants)
const cols = {
admitDate: colFirst('วันที่Admit','วันรับบริการ','วันที่รับ','วันที่บริการ','วันเข้า','วันที่เข้า','Admit'),
dischargeDate:colFirst('วันที่จำหน่าย','วันจำหน่าย','วันออก','จำหน่าย'),
hn: colFirst('HN'),
an: colFirst('AN'),
pid: colFirst('หมายเลขบัตร','บัตรประชาชน','PID','บัตร'),
name: colFirst('ชื่อ-สกุล','ชื่อสกุล','ชื่อ'),
age: colFirst('อายุ'),
ward: colFirst('ตึก','Ward','หอผู้ป่วย'),
icd10: colFirst('ICD','รหัสโรค'),
rightType: colFirst('สิทธิการรักษา','สิทธิ์การรักษา','สิทธิ์','สิทธิ'),
patPay: colFirst('ผู้ป่วยชำระ','ผู้ป่วยจ่าย','ส่วนผู้ป่วย'),
rightPay: colFirst('สิทธิชำระ','สิทธิ์ชำระ','สิทธิจ่าย','ส่วนสิทธิ','ส่วนเบิก'),
totalCost: colFirst('ค่าใช้จ่ายทั้งหมด','ค่าใช้จ่ายรวม','ค่าใช้จ่าย','รวมค่าใช้จ่าย','ยอดรวม','ค่ารักษา'),
};
// Diagnostic log for column positions
log(` 📊 ${filename.replace(/.*[\\/]/,'')} header row ${headerRow}: HN[${cols.hn}] AN[${cols.an}] Admit[${cols.admitDate}] Dis[${cols.dischargeDate}] Right[${cols.rightType}] Cost[${cols.totalCost}]`, 'ok');
if (cols.hn < 0) log(` ⚠️ ไม่พบคอลัมน์ HN ใน ${filename}`, 'warn');
if (cols.rightType < 0) log(` ⚠️ ไม่พบคอลัมน์ สิทธิการรักษา — ตรวจสอบหัวตาราง`, 'warn');
if (cols.admitDate < 0) log(` ⚠️ ไม่พบคอลัมน์ วันที่ Admit/รับบริการ — วันที่จะว่าง`, 'warn');
if (cols.totalCost < 0) log(` ⚠️ ไม่พบคอลัมน์ ค่าใช้จ่าย — ยอดจะเป็น 0`, 'warn');
let count = 0;
const records = [];
for (let i = headerRow + 1; i < rows.length; i++) {
const row = rows[i];
if (!row || row.every(c => !c)) continue;
const hn = String(row[cols.hn] || '').trim();
if (!hn) continue;
const disDate = parseDateStr(row[cols.dischargeDate]);
const admDate = parseDateStr(row[cols.admitDate]);
// คำนวณ ptype ก่อน เพื่อใช้กำหนด disMonth ให้ถูกต้อง
const ptype = filename.toLowerCase().includes('ipd') ? 'IPD'
: filename.toLowerCase().includes('opd') ? 'OPD' : 'IPD';
// OPD: ใช้วันที่รับบริการ (admitDate) เป็นฐานเดือน เพราะ OPD มักเข้า-ออกวันเดียวกัน
// IPD: ใช้วันที่จำหน่าย (dischargeDate) ตามปกติ
const disMonth = ptype === 'OPD' ? monthKey(admDate) : monthKey(disDate);
records.push({
hn,
an: String(row[cols.an] || '').trim(),
pid: String(row[cols.pid] || '').trim(),
name: String(row[cols.name] || '').trim(),
admitDate: admDate,
dischargeDate: disDate,
ward: String(row[cols.ward] || '').trim(),
icd10: String(row[cols.icd10] || '').trim(),
rightType: String(row[cols.rightType] || '').trim(),
rightNorm: normalizeRight(row[cols.rightType]),
patPay: toNum(row[cols.patPay]),
rightPay: toNum(row[cols.rightPay]),
totalCost: toNum(row[cols.totalCost]),
disMonth,
ptype,
sourceFile: filename,
repRecords: [],
});
count++;
}
return { records, count };
}
// =============================================
// ECLAIM PARSER
// =============================================
function parseEclaimFile(workbook, filename) {
// Look for 'Detail' sheet first
let sheetName = workbook.SheetNames.find(n => n.toLowerCase().includes('detail')) || workbook.SheetNames[0];
const ws = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json(ws, {header:1, defval:''});
// Find header row containing 'REP No.' or 'HN'
let headerRow = -1;
for (let i = 0; i < Math.min(20, rows.length); i++) {
const joined = rows[i].map(c => String(c)).join(' ');
if (joined.includes('REP No.') || (joined.includes('HN') && joined.includes('PID'))) {
headerRow = i; break;
}
}
if (headerRow < 0) { log(`ไม่พบ header ใน ${filename}`, 'err'); return {records:[], count:0}; }
const header = rows[headerRow].map(c => String(c).trim());
const findCol = (names) => {
for (const name of names) {
const idx = header.findIndex(h => h.includes(name));
if (idx >= 0) return idx;
}
return -1;
};
// Fund columns are multi-row headers - scan rows headerRow to headerRow+3
// Build a merged header
const mergedHeader = [...header];
for (let r = headerRow + 1; r <= Math.min(headerRow + 3, rows.length - 1); r++) {
rows[r].forEach((c, i) => {
if (c && !mergedHeader[i]) mergedHeader[i] = String(c).trim();
else if (c) mergedHeader[i] += ' ' + String(c).trim();
});
}
const cols = {
repNo: findCol(['REP No.']),
hn: findCol(['HN']),
an: findCol(['AN']),
pid: findCol(['PID']),
name: findCol(['ชื่อ-สกุล','ชื่อ']),
admDate: findCol(['วันเข้ารักษา']),
disDate: findCol(['วันจำหน่าย']),
netComp: findCol(['ชดเชยสุทธิ']),
compFrom: findCol(['ชดเชยจาก']),
fundMain: findCol(['กองทุนหลัก']),
fundSub: findCol(['กองทุนย่อย']),
errorCode:findCol(['Error Code']),
deny: findCol(['Deny']),
};
// Find fund breakdown columns by scanning full merged headers
const fundCols = {};
mergedHeader.forEach((h, i) => {
if (h.includes('ค่าใช้จ่ายสูง') || h.includes('HC') && h.length < 10) fundCols.hc = fundCols.hc || i;
if (h.includes('อุบัติเหตุฉุกเฉิน') || h === 'AE') fundCols.ae = fundCols.ae || i;
if (h.includes('อวัยวะเทียม') || h.includes('INST') && h.length < 10) fundCols.inst = fundCols.inst || i;
if (h.includes('ผู้ป่วยใน') || h === 'IP') fundCols.ip = fundCols.ip || i;
if (h.includes('โรคเฉพาะ') || h === 'DMIS') fundCols.dmis = fundCols.dmis || i;
if (h === 'DRUG') fundCols.drug = fundCols.drug || i;
});
// Fallback fixed indices based on known format
if (!fundCols.hc) fundCols.hc = 54;
if (!fundCols.ae) fundCols.ae = 56;
if (!fundCols.inst) fundCols.inst = 64;
if (!fundCols.ip) fundCols.ip = 66;
if (!fundCols.dmis) fundCols.dmis = 75;
if (!fundCols.drug) fundCols.drug = 96;
const dataStart = headerRow + 3; // Skip sub-headers (rows 6,7 in eclaim = 2 sub-header rows)
let count = 0;
const records = [];
for (let i = dataStart; i < rows.length; i++) {
const row = rows[i];
if (!row || row.every(c => !c)) continue;
const hn = String(row[cols.hn] || '').trim();
const repNo = String(row[cols.repNo] || '').trim();
if (!hn && !repNo) continue;
if (hn === 'HN') continue; // repeated header
records.push({
repNo,
hn,
an: String(row[cols.an] || '').trim(),
pid: String(row[cols.pid] || '').trim(),
name: String(row[cols.name] || '').trim(),
admDate: parseDateStr(row[cols.admDate]),
disDate: parseDateStr(row[cols.disDate]),
netComp: toNum(row[cols.netComp]),
compFrom: String(row[cols.compFrom] || '').trim(),
fundMain: String(row[cols.fundMain] || '').trim(),
fundSub: String(row[cols.fundSub] || '').trim(),
errorCode:String(row[cols.errorCode] || '').trim(),
isDeny: String(row[cols.deny] || '').trim().toUpperCase() === 'DENY',
hc: toNum(row[fundCols.hc]),
ae: toNum(row[fundCols.ae]),
inst: toNum(row[fundCols.inst]),
ip: toNum(row[fundCols.ip]),
dmis: toNum(row[fundCols.dmis]),
drug: toNum(row[fundCols.drug]),
sourceFile: filename,
});
count++;
}
return { records, count };
}
// =============================================
// FILE UPLOAD HANDLERS
// =============================================
async function handleImpUpload(input) {
const files = Array.from(input.files);
for (const file of files) {
log(`📄 กำลังอ่าน IMP: ${file.name} ...`);
try {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, {type:'array', cellDates:false});
const {records, count} = parseImpFile(wb, file.name);
// Avoid exact duplicates by HN+AN+admitDate+totalCost
let added = 0, dupCount = 0, errCount = 0;
for (const rec of records) {
// Dedup key: HN + AN + admitDate (exact date) + totalCost
// ใช้ admitDate แทน disMonth เพื่อรองรับ OPD ที่มีหลาย visit/เดือน และ AN ว่าง
const admDt = rec.admitDate ? rec.admitDate.toISOString().split('T')[0] : '';
const dup = store.impRecords.find(r => {
const rDt = r.admitDate ? r.admitDate.toISOString().split('T')[0] : '';
return r.hn === rec.hn && r.an === rec.an && rDt === admDt && r.totalCost === rec.totalCost;
});
if (!dup) { store.impRecords.push(rec); added++; }
else dupCount++;
}
store.impFileCount++;
// บันทึก file-level tracking (ตรวจสอบไม่ให้ซ้ำ)
if (!store.impFiles.find(f => f.name === file.name)) {
store.impFiles.push({name: file.name, added, total: count, date: new Date().toLocaleDateString('th-TH')});
} else {
const existing = store.impFiles.find(f => f.name === file.name);
existing.added += added; existing.total += count; // เพิ่มยอด
}
log(`✅ IMP: ${file.name} — พบ ${count} รายการ, เพิ่มใหม่ ${added} ราย${dupCount>0?' | ซ้ำ '+dupCount+' ราย (ข้าม)':''}`, 'ok');
renderImpFileList();
} catch(e) {
log(`❌ Error: ${file.name} — ${e.message}`, 'err');
}
}
input.value = '';
refreshAll();
}
async function handleEclaimUpload(input) {
const files = Array.from(input.files);
for (const file of files) {
log(`📋 กำลังอ่าน REP/eclaim: ${file.name} ...`);
try {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, {type:'array', cellDates:false});
// Auto-detect format by sheet names or column content
const sheetNames = wb.SheetNames; // keep original case for Thai check
const sheetNamesLC = sheetNames.map(s => s.toLowerCase());
let result;
if (sheetNamesLC.includes('individual')) {
// New REP format: รายงานสรุปการขอเบิกชดเชย (6902_OP_xx)
log(` 📂 ตรวจพบ: REP format ใหม่ (Individual sheet)`, 'ok');
result = parseRepSimpleFile(wb, file.name);
if (result.meta && result.meta.batch) {
log(` 🗂️ งวด: ${result.meta.batch} | ${result.meta.hospName || ''}`, 'ok');
}
} else if (sheetNames.some(s => s.includes('รายละเอียด') && s.toUpperCase().includes('OP') && !s.includes('อุทธรณ์'))) {
// STM format: Statement รายละเอียด(ข้อมูลปกติ) 1 OP
log(` 📂 ตรวจพบ: STM format (รายละเอียด OP sheet)`, 'ok');
result = parseStmFile(wb, file.name);
if (result.meta && result.meta.hospName) {
log(` 🗂️ หน่วยบริการ: ${result.meta.hospName}`, 'ok');
}
} else if (sheetNamesLC.some(s => s.includes('detail'))) {
// Old eclaim format
log(` 📂 ตรวจพบ: eclaim format เดิม (Detail sheet)`, 'ok');
result = parseEclaimFile(wb, file.name);
} else {
// Try rep_simple first (simpler heuristic)
const ws0 = wb.Sheets[wb.SheetNames[0]];
const testRows = XLSX.utils.sheet_to_json(ws0, {header:1, defval:''}).slice(0, 15);
const hasRepNo = testRows.some(r => r.some(c => String(c).includes('REP_NO')));
const hasTranId = testRows.some(r => r.some(c => String(c).includes('TRAN_ID')));
if (hasTranId) {
log(` 📂 ตรวจพบ: STM format (TRAN_ID detected)`, 'ok');
result = parseStmFile(wb, file.name);
} else {
result = hasRepNo ? parseRepSimpleFile(wb, file.name) : parseEclaimFile(wb, file.name);
log(` 📂 ตรวจพบ: ${hasRepNo ? 'REP format ใหม่' : 'eclaim format เดิม'} (auto-detect)`, 'ok');
}
}
let added = 0, dupCount = 0;
for (const rec of result.records) {
// Dedup key per format:
// - STM: TRAN_ID unique per transaction row
// - REP Simple (rep_simple): repNo + rowSeq (unique per row, 1 row per fund per patient)
// - eclaim format: repNo + hn + seqNo (old format มี 1 row ต่อ patient)
const getKey = (r) => {
if (r.fileType === 'stm')
return `stm|${r.tranId||r.hn}|${r.seqNo||r.netComp}`;
if (r.fileType === 'rep_simple')
return `${r.repNo}|ROW|${r.rowSeq}|${r.fundMain}|${r.fundSub}|${r.netComp}`;
return `${r.repNo}|${r.hn}|${r.seqNo || r.netComp}`;
};
const key = getKey(rec);
if (!store.eclaimRecords.some(r => getKey(r) === key)) {
store.eclaimRecords.push(rec);
added++;
} else dupCount++;
}
store.eclaimFileCount++;
// บันทึก file-level tracking
if (!store.eclaimFiles.find(f => f.name === file.name)) {
store.eclaimFiles.push({name: file.name, added, total: result.count, date: new Date().toLocaleDateString('th-TH')});
} else {
const existing = store.eclaimFiles.find(f => f.name === file.name);
existing.added += added; existing.total += result.count;
}
log(`✅ ${file.name} — พบ ${result.count} รายการ, เพิ่มใหม่ ${added} ราย${dupCount>0?' | ซ้ำ '+dupCount+' รายการ (ข้าม)':''}`, 'ok');
renderEclaimFileList();
} catch(e) {
log(`❌ Error: ${file.name} — ${e.message}`, 'err');
}
}
input.value = '';
refreshAll();
}
function renderImpFileList() {
const el = document.getElementById('imp-file-list');
if (!el) return;
el.innerHTML = store.impFiles.length === 0
? '<div style="font-size:12px;color:#a0aec0;padding:4px 0">ยังไม่ได้นำเข้าไฟล์ IMP</div>'
: store.impFiles.map((f, idx) => `
<div style="font-size:12px;padding:4px 8px;background:#d1fae5;border-radius:4px;margin-top:4px;display:flex;align-items:center;gap:6px">
<span style="flex:1">✅ <b>${(f.name||'').replace(/.*[\\/]/,'')}</b>
<span style="color:#065f46"> (${fmtInt(f.added)}/${fmtInt(f.total)} ราย)</span>
<span style="color:#a0aec0;font-size:11px"> ${f.date||''}</span>
</span>
<button class="btn btn-danger btn-sm" style="padding:2px 8px;font-size:11px"
onclick="deleteImpByIndex(${idx})">🗑️ ลบ</button>
</div>`).join('');
}
function renderEclaimFileList() {
const el = document.getElementById('eclaim-file-list');
if (!el) return;
el.innerHTML = store.eclaimFiles.length === 0
? '<div style="font-size:12px;color:#a0aec0;padding:4px 0">ยังไม่ได้นำเข้าไฟล์ eclaim</div>'
: store.eclaimFiles.map((f, idx) => `
<div style="font-size:12px;padding:4px 8px;background:#dbeafe;border-radius:4px;margin-top:4px;display:flex;align-items:center;gap:6px">
<span style="flex:1">✅ <b>${(f.name||'').replace(/.*[\\/]/,'')}</b>
<span style="color:#1e40af"> (${fmtInt(f.added)}/${fmtInt(f.total)} ราย)</span>
<span style="color:#a0aec0;font-size:11px"> ${f.date||''}</span>
</span>
<button class="btn btn-danger btn-sm" style="padding:2px 8px;font-size:11px"
onclick="deleteEclaimByIndex(${idx})">🗑️ ลบ</button>
</div>`).join('');
}
// ใช้ index แทนชื่อไฟล์ใน onclick เพื่อหลีกเลี่ยง quote ใน HTML attribute
function deleteImpByIndex(idx) {
const f = store.impFiles[idx];
if (!f) return;
const shortName = (f.name||'').replace(/.*[\\/]/,'');
if (!confirm(`ลบข้อมูล IMP ทั้งหมดจากไฟล์นี้?\n📄 ${shortName}\n(จะลบ ${fmtInt(f.added)} รายการออกจากระบบ)`)) return;
const filename = f.name;
const before = store.impRecords.length;
store.impRecords = store.impRecords.filter(r => r.sourceFile !== filename);
const deleted = before - store.impRecords.length;
store.impFiles = store.impFiles.filter(x => x.name !== filename);
store.impFileCount = store.impFiles.length;
log(`🗑️ ลบ IMP จาก "${shortName}": ${deleted} ราย`, 'warn');
refreshAll();
}
function deleteEclaimByIndex(idx) {
const f = store.eclaimFiles[idx];
if (!f) return;
const shortName = (f.name||'').replace(/.*[\\/]/,'');
if (!confirm(`ลบข้อมูล eclaim ทั้งหมดจากไฟล์นี้?\n📋 ${shortName}\n(จะลบ ${fmtInt(f.added)} รายการออกจากระบบ)`)) return;
const filename = f.name;
const before = store.eclaimRecords.length;
store.eclaimRecords = store.eclaimRecords.filter(r => r.sourceFile !== filename);
const deleted = before - store.eclaimRecords.length;
store.eclaimFiles = store.eclaimFiles.filter(x => x.name !== filename);
store.eclaimFileCount = store.eclaimFiles.length;
log(`🗑️ ลบ eclaim จาก "${shortName}": ${deleted} รายการ`, 'warn');
refreshAll();
}
// =============================================
// REP SIMPLE PARSER (format: 6902_OP_xx / Individual sheet)
// =============================================
function parseRepSimpleFile(workbook, filename) {
const sheetName = workbook.SheetNames.find(n => n === 'Individual') || workbook.SheetNames[0];
const ws = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json(ws, {header:1, defval:''});
// Find header row: contains 'HN' and 'REP_NO'
let headerRow = -1;
for (let i = 0; i < Math.min(15, rows.length); i++) {
const joined = rows[i].map(c => String(c)).join('|');
if (joined.includes('HN') && joined.includes('REP_NO')) { headerRow = i; break; }
if (joined.includes('HN') && joined.includes('กองทุน')) { headerRow = i; break; }
}
if (headerRow < 0) { log(`ไม่พบ header ใน ${filename}`, 'err'); return {records:[], count:0, meta:{}}; }
const header = rows[headerRow].map(c => String(c).trim());
const ci = (name) => header.findIndex(h => h.includes(name));
const cols = {
seq: ci('ลำดับ'),
transferDate:ci('วันที่โอน'),
hn: ci('HN'),
an: ci('AN'),
ptype: ci('ประเภท'),
pid: ci('เลขบัตรประชาชน') >= 0 ? ci('เลขบัตรประชาชน') : ci('PID'),
name: ci('ชื่อ'),
serviceDate: ci('วันที่เข้ารับบริการ') >= 0 ? ci('วันที่เข้ารับบริการ') : ci('วันที่'),
netComp: ci('จ่ายชดเชยสุทธิ') >= 0 ? ci('จ่ายชดเชยสุทธิ') : ci('ชดเชย'),
repNo: ci('REP_NO') >= 0 ? ci('REP_NO') : ci('REP No'),
fundMain: ci('กองทุนหลัก'),
fundSub: ci('กองทุนย่อย'),
detail: ci('รายละเอียด'),
seqNo: ci('seq_no'),
invoiceNo: ci('invoice_no'),
};
// Read metadata from top rows (งวด, ชื่อหน่วยบริการ, etc.)
const meta = {};
for (let i = 0; i < headerRow; i++) {
const r = rows[i];
const label = String(r[1] || '').trim();
const val = String(r[2] || '').trim();
if (label.includes('งวด')) meta.batch = val;
if (label.includes('ชื่อหน่วย')) meta.hospName = val;
if (label.includes('รหัสหน่วย')) meta.hospCode = val;
if (label.includes('วันที่จัดพิมพ์')) meta.printDate = val;
}
let count = 0;
const records = [];
for (let i = headerRow + 1; i < rows.length; i++) {
const row = rows[i];
if (!row || row.every(c => !c)) continue;
const hn = String(row[cols.hn] || '').trim();
// Skip summary row (has "รวม" or no HN that looks valid)
if (!hn || hn === 'nan' || String(row[cols.serviceDate] || '').includes('รวม')) continue;
const net = toNum(row[cols.netComp]);
if (net === 0 && !hn) continue;
// rowSeq = ลำดับที่ในไฟล์ (unique per row) ใช้เป็น dedup key ที่แม่นยำ
const rowSeq = String(row[cols.seq] || (headerRow + count + 1)).trim();
const seqNoVal = String(row[cols.seqNo] || '').trim();
records.push({
repNo: String(row[cols.repNo] || '').trim(),
hn,
an: String(row[cols.an] || '').trim(),
pid: String(row[cols.pid] || '').trim(),
name: String(row[cols.name] || '').trim(),
ptype: String(row[cols.ptype] || '').trim(),
serviceDate: parseDateStr(row[cols.serviceDate]),
transferDate:parseDateStr(row[cols.transferDate]),
netComp: net,
fundMain: String(row[cols.fundMain] || '').trim(),
fundSub: String(row[cols.fundSub] || '').trim(),
detail: String(row[cols.detail] || '').trim(),
seqNo: seqNoVal, // visit-level seq (shared across fund rows of same visit)
rowSeq: rowSeq, // row-level seq = ลำดับที่ (unique per row in file)
invoiceNo: String(row[cols.invoiceNo] || '').trim(),
// Derived fund category
fundCat: getFundCategory(String(row[cols.fundMain] || ''), String(row[cols.fundSub] || '')),
isDeny: false,
// Empty eclaim-format fields for compatibility
admDate: null, disDate: null,
compFrom: '', errorCode: '',
hc:0, ae:0, inst:0, ip:0, dmis:0, drug:0, // will be set by fundCat below
fileType: 'rep_simple',
sourceFile: filename,
});
// Set fund bucket based on category
const last = records[records.length - 1];
if (last.fundCat === 'hc') last.hc = net;
if (last.fundCat === 'ae') last.ae = net;
if (last.fundCat === 'ae_drug') { last.ae = net; last.drug = net; }
if (last.fundCat === 'inst') last.inst = net;
if (last.fundCat === 'drug') last.drug = net;
if (last.fundCat === 'dmis') last.dmis = net;
if (last.fundCat === 'ip') last.ip = net;
count++;
}
return { records, count, meta };
}
// =============================================
// STM PARSER (format: STM_11422_OPUCS_xx — Statement รายละเอียด)
// Sheet: รายละเอียด(ข้อมูลปกติ) 1 OP
// Columns: REP | TRAN_ID | HN | AN | PID | ชื่อ-สกุล | วันเข้ารักษา | วันจำหน่าย | MAININSCL | PROJCODE | ... | ยอดชดเชยทั้งสิ้น | DRUG | VA | SEQ NO
// =============================================
function parseStmFile(workbook, filename) {
// Find the target sheet: ชื่อมี 'รายละเอียด' และ 'OP' (ไม่ใช่ 'อุทธรณ์')
const sheetName = workbook.SheetNames.find(n =>
n.includes('รายละเอียด') && n.toUpperCase().includes('OP') && !n.includes('อุทธรณ์')
) || workbook.SheetNames[0];
const ws = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json(ws, {header:1, defval:''});
// Find header row: must contain 'HN' and ('TRAN_ID' or 'SEQ NO')
let headerRow = -1;
for (let i = 0; i < Math.min(20, rows.length); i++) {
const cells = rows[i].map(c => String(c).trim());
const joined = cells.join('|');
if (joined.includes('HN') && (joined.includes('TRAN_ID') || joined.includes('SEQ NO') || joined.includes('SEQ_NO'))) {
headerRow = i;
break;
}
}
if (headerRow < 0) {
log(`ไม่พบ header ใน STM ${filename}`, 'err');
return {records:[], count:0, meta:{}};
}
const header = rows[headerRow].map(c => String(c).trim());
const ci = (...names) => {
for (const name of names) {
const i = header.findIndex(h => h === name || h.includes(name));
if (i >= 0) return i;
}
return -1;
};
const cols = {
repNo: ci('REP'),
tranId: ci('TRAN_ID'),
hn: ci('HN'),
an: ci('AN'),
pid: ci('PID'),
name: ci('ชื่อ-สกุล','ชื่อสกุล','ชื่อ'),
admDate: ci('วันเข้ารักษา','วันรับบริการ','วันที่เข้า','วันที่รับ'),
disDate: ci('วันจำหน่าย','วันออก','วันจำหน่าย'),
inscl: ci('MAININSCL'),
projcode: ci('PROJCODE'),
netComp: ci('ยอดชดเชยทั้งสิ้น','ยอดชดเชย'),
drug: ci('DRUG'),
va: ci('VA'),
seqNo: ci('SEQ NO','SEQ_NO','seqno'),
};
// Extract metadata from rows above header
const meta = {};
for (let i = 0; i < headerRow; i++) {
const r = rows[i];
const line = r.map(c => String(c)).join(' ');
if (line.includes('เลขที่เอกสาร') || line.includes('REP')) {
for (const c of r) {
const v = String(c).trim();
if (v.match(/^\d{10,}$/) || v.match(/^REP\d/i)) { meta.docRepNo = v; break; }
}
}
if (line.includes('โรงพยาบาล') || line.includes('หน่วยบริการ')) {
for (let j = 0; j < r.length - 1; j++) {
if (String(r[j]).includes('โรงพยาบาล') || String(r[j]).includes('หน่วยบริการ')) {
meta.hospName = String(r[j+1] || r[j]).trim(); break;
}
}
}
if (line.includes('ออกรายงาน') || line.includes('วันที่พิมพ์')) {
for (let j = 0; j < r.length - 1; j++) {
if (String(r[j]).includes('ออกรายงาน') || String(r[j]).includes('พิมพ์')) {
meta.printDate = String(r[j+1] || '').trim(); break;
}
}
}
}
const records = [];
let count = 0;
for (let i = headerRow + 1; i < rows.length; i++) {
const row = rows[i];
if (!row || row.every(c => !c && c !== 0)) continue;
const rawHn = String(row[cols.hn] ?? '').trim();
// Skip summary/total rows (no valid HN, or HN is text like 'รวม')
if (!rawHn || rawHn === 'nan' || /[ก-ฮ]/.test(rawHn)) continue;
// Strip leading zeros from HN
const hn = rawHn.replace(/^0+/, '') || rawHn;
const tranId = String(row[cols.tranId] ?? '').trim();
const netComp = cols.netComp >= 0 ? toNum(row[cols.netComp]) : 0;
const drugAmt = cols.drug >= 0 ? toNum(row[cols.drug]) : 0;
const hcAmt = netComp - drugAmt;
const inscl = String(row[cols.inscl] ?? '').trim(); // UCS, WEL, etc.
const projcode = String(row[cols.projcode] ?? '').trim(); // HC16, AE04, etc.
const seqNo = cols.seqNo >= 0 ? String(row[cols.seqNo] ?? '').trim() : '';
const repNo = cols.repNo >= 0 ? String(row[cols.repNo] ?? '').trim() : (meta.docRepNo || '');
const admDateVal = cols.admDate >= 0 ? parseDateStr(row[cols.admDate]) : null;
const disDateVal = cols.disDate >= 0 ? parseDateStr(row[cols.disDate]) : null;
// Derive fund category from projcode (MAININSCL = UCS, WEL; PROJCODE = HC16, AE04, etc.)
const fundCat = getFundCategory(projcode || inscl, inscl);
records.push({
repNo,
tranId,
hn,
an: String(row[cols.an] ?? '').trim(),
pid: String(row[cols.pid] ?? '').trim(),
name: String(row[cols.name] ?? '').trim(),
admDate: admDateVal,
disDate: disDateVal,
serviceDate: admDateVal,
transferDate: null,
netComp,
hc: Math.max(0, hcAmt),
ae: 0,
inst: 0,
ip: 0,
dmis: 0,
drug: drugAmt,
fundMain: projcode || inscl,
fundSub: inscl,
fundCat,
seqNo,
isDeny: false,
compFrom: '',
errorCode: '',
fileType: 'stm',
sourceFile: filename,
});
count++;
}
return { records, count, meta };
}
// =============================================
// FUND CATEGORY MAPPING
// =============================================
function getFundCategory(fundMain, fundSub) {
const code = (fundMain || '').toUpperCase();
const sub = (fundSub || '').toUpperCase();
if (code.startsWith('AE')) {
if (sub.includes('DRUG')) return 'ae_drug';
return 'ae';
}
if (code.startsWith('DM') || code.startsWith('PP')) return 'dmis';
if (code.startsWith('IP')) return 'ip';
if (code.startsWith('HC') || code.startsWith('OP')) {
if (sub.includes('INST') || sub.includes('DENTURE') || sub === 'OPINST') return 'inst';
if (sub.includes('DRUG') || sub.includes('HERB')) return 'drug';
return 'hc';
}
return 'other';
}
// Fund category Thai label
const FUND_LABELS = {
hc:'HC ค่าใช้จ่ายสูง', ae:'AE ฉุกเฉิน', ae_drug:'AE ยา', inst:'INST อวัยวะเทียม',
drug:'DRUG ยา', dmis:'DMIS โรคเฉพาะ', ip:'IP ผู้ป่วยใน', other:'อื่นๆ'
};
// =============================================
// MATCHING ENGINE
// =============================================
function buildEclaimIndex() {
const byHn = {};
const byPid = {};
for (const rec of store.eclaimRecords) {
if (rec.hn) {
if (!byHn[rec.hn]) byHn[rec.hn] = [];
byHn[rec.hn].push(rec);
}
if (rec.pid) {
if (!byPid[rec.pid]) byPid[rec.pid] = [];
byPid[rec.pid].push(rec);
}
}
return {byHn, byPid};
}
function getMatchStatus(imp, eclaimMatches) {
if (!eclaimMatches || eclaimMatches.length === 0) return 'none';
const hasDeny = eclaimMatches.some(r => r.isDeny);
if (hasDeny && eclaimMatches.length === eclaimMatches.filter(r => r.isDeny).length) return 'deny';
const totalRep = eclaimMatches.reduce((s, r) => s + r.netComp, 0);
const diff = Math.abs(totalRep - imp.rightPay);
if (diff < 1 || (imp.rightPay > 0 && diff / imp.rightPay < 0.05)) return 'matched';
if (totalRep > 0) return 'partial';
return 'none';
}
function getMatchedData() {
const idx = buildEclaimIndex();
return store.impRecords.map((imp, _storeIdx) => {
let matches = idx.byHn[imp.hn] || [];
if (matches.length === 0 && imp.pid) matches = idx.byPid[imp.pid] || [];
if (imp.an && matches.length > 1) {
const anMatch = matches.filter(r => r.an === imp.an);
if (anMatch.length > 0) matches = anMatch;
}
const totalRep = matches.reduce((s, r) => s + r.netComp, 0);
const totalHc = matches.reduce((s, r) => s + r.hc, 0);
const totalAe = matches.reduce((s, r) => s + r.ae, 0);
const totalInst = matches.reduce((s, r) => s + r.inst, 0);
const totalIp = matches.reduce((s, r) => s + r.ip, 0);
const totalDmis = matches.reduce((s, r) => s + r.dmis, 0);
const totalDrug = matches.reduce((s, r) => s + r.drug, 0);
const repNos = [...new Set(matches.map(r => r.repNo).filter(Boolean))].join(', ');
const status = getMatchStatus(imp, matches);
const diff = totalRep - imp.rightPay;
// Build กองทุนหลัก breakdown map { HC16: 150, AE04: 500, ... }
const fundCodeMap = {};
const fundSubMap = {};
matches.forEach(r => {
if (r.fundMain) fundCodeMap[r.fundMain] = (fundCodeMap[r.fundMain] || 0) + r.netComp;
if (r.fundSub) fundSubMap[r.fundSub] = (fundSubMap[r.fundSub] || 0) + r.netComp;
});
return { ...imp, _storeIdx, matches, totalRep, totalHc, totalAe, totalInst, totalIp, totalDmis, totalDrug, repNos, status, diff, fundCodeMap, fundSubMap };
});
}
// =============================================
// REFRESH ALL VIEWS
// =============================================
function refreshAll() {
updateImportStats();
populateFilters();
renderDashboard();
renderMonthly();
renderMatching();
renderAccounting();
renderEclaimTable();
renderImpFileList();
renderEclaimFileList();
saveToStorage();
}
function updateImportStats() {
document.getElementById('imp-record-count').textContent = fmtInt(store.impRecords.length);
document.getElementById('eclaim-record-count').textContent = fmtInt(store.eclaimRecords.length);
const repNos = new Set(store.eclaimRecords.map(r => r.repNo).filter(Boolean));
document.getElementById('rep-count').textContent = fmtInt(repNos.size);
const months = new Set(store.impRecords.map(r => r.disMonth).filter(m => m !== 'ไม่ระบุ'));
document.getElementById('month-count').textContent = fmtInt(months.size);
}
function getUniqueMonths() {
return [...new Set(store.impRecords.map(r => r.disMonth))].filter(m => m !== 'ไม่ระบุ').sort();
}
function getUniqueRights() {
return [...new Set(store.impRecords.map(r => r.rightNorm))].sort();
}
function populateFilters() {
const months = getUniqueMonths();
const rights = getUniqueRights();
['filter-month','match-filter-month','acc-month'].forEach(id => {
const sel = document.getElementById(id);
const val = sel.value;
sel.innerHTML = '<option value="">-- ทุกเดือน --</option>';
months.forEach(m => sel.innerHTML += `<option value="${m}" ${val===m?'selected':''}>${monthLabel(m)}</option>`);
});
['filter-right','match-filter-right','acc-right'].forEach(id => {
const sel = document.getElementById(id);
const val = sel.value;
sel.innerHTML = '<option value="">-- ทุกสิทธิ --</option>';
rights.forEach(r => sel.innerHTML += `<option value="${r}" ${val===r?'selected':''}>${r}</option>`);
});
}
// =============================================
// DASHBOARD
// =============================================
let chartRight = null, chartStatus = null;
function renderDashboard() {
const data = getMatchedData();
const total = data.length;
const matched = data.filter(r => r.status === 'matched').length;
const partial = data.filter(r => r.status === 'partial').length;
const noneCount = data.filter(r => r.status === 'none').length;
const totalAmt = data.reduce((s, r) => s + r.rightPay, 0);
const matchedAmt = data.filter(r => r.status === 'matched').reduce((s, r) => s + r.totalRep, 0);
const partialAmt = data.filter(r => r.status === 'partial').reduce((s, r) => s + r.totalRep, 0);
const noneAmt = data.filter(r => r.status === 'none').reduce((s, r) => s + r.rightPay, 0);
document.getElementById('d-total-count').textContent = fmtInt(total);
document.getElementById('d-total-amt').textContent = '฿ ' + fmt(totalAmt);
document.getElementById('d-matched-count').textContent = fmtInt(matched);
document.getElementById('d-matched-amt').textContent = '฿ ' + fmt(matchedAmt);
document.getElementById('d-partial-count').textContent = fmtInt(partial);
document.getElementById('d-partial-amt').textContent = '฿ ' + fmt(partialAmt);
document.getElementById('d-none-count').textContent = fmtInt(noneCount);
document.getElementById('d-none-amt').textContent = '฿ ' + fmt(noneAmt);
document.getElementById('d-imp-files').textContent = fmtInt(store.impFileCount);
document.getElementById('d-eclaim-files').textContent = 'REP ' + fmtInt(store.eclaimFileCount) + ' ฉบับ';
// Chart by right
const rightMap = {};
data.forEach(r => {
rightMap[r.rightNorm] = (rightMap[r.rightNorm] || 0) + r.rightPay;
});
const rightLabels = Object.keys(rightMap);
const rightVals = Object.values(rightMap);
const colors = ['#2980b9','#27ae60','#e67e22','#8e44ad','#e74c3c','#16a085','#f39c12','#2c3e50'];
if (chartRight) chartRight.destroy();
chartRight = new Chart(document.getElementById('chart-by-right'), {
type: 'bar',
data: {
labels: rightLabels,
datasets: [{label:'ยอดตั้งหนี้ (บาท)', data: rightVals, backgroundColor: colors}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { ticks: { callback: v => (v/1000).toFixed(0) + 'K' } },
x: { ticks: { font: { size: 11 } } }
}
}
});
// Pie status chart
if (chartStatus) chartStatus.destroy();
chartStatus = new Chart(document.getElementById('chart-status'), {
type: 'doughnut',
data: {
labels: ['จับคู่ครบ','บางส่วน','ไม่มี REP','DENY'],
datasets: [{
data: [matched, partial, noneCount, data.filter(r=>r.status==='deny').length],
backgroundColor: ['#27ae60','#e67e22','#e74c3c','#8e44ad']
}]
},
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } } }
});
// Monthly table
const monthlyMap = {};
data.forEach(r => {
const key = `${r.disMonth}||${r.rightNorm}`;
if (!monthlyMap[key]) monthlyMap[key] = {month:r.disMonth, right:r.rightNorm, count:0, total:0, patPay:0, rightPay:0, rep:0};
monthlyMap[key].count++;
monthlyMap[key].total += r.totalCost;
monthlyMap[key].patPay += r.patPay;
monthlyMap[key].rightPay += r.rightPay;
monthlyMap[key].rep += r.totalRep;
});
const rows = Object.values(monthlyMap).sort((a,b) => a.month.localeCompare(b.month) || a.right.localeCompare(b.right));
document.getElementById('dash-month-badge').textContent = rows.length + ' รายการ';
document.getElementById('dash-monthly-body').innerHTML = rows.length === 0
? '<tr><td colspan="9" style="text-align:center;padding:24px;color:#a0aec0">ยังไม่มีข้อมูล</td></tr>'
: rows.map(r => {
const diff = r.rep - r.rightPay;
const pct = r.rightPay > 0 ? (r.rep/r.rightPay*100).toFixed(1) : '0';
let status = pct >= 95 ? '<span class="badge-matched">✅ ครบ</span>' : pct > 0 ? '<span class="badge-partial">⚠️ บางส่วน</span>' : '<span class="badge-none">❌ ยังไม่มี</span>';
return `<tr>
<td>${monthLabel(r.month)}</td>
<td>${r.right}</td>
<td class="num">${fmtInt(r.count)}</td>
<td class="num">${fmt(r.total)}</td>
<td class="num">${fmt(r.patPay)}</td>
<td class="num">${fmt(r.rightPay)}</td>
<td class="num">${fmt(r.rep)}</td>
<td class="num" style="color:${diff<0?'#e74c3c':diff>0?'#27ae60':'inherit'}">${fmt(diff)}</td>
<td>${status}</td>
</tr>`;
}).join('');
}
// =============================================
// MONTHLY VIEW
// =============================================
function renderMonthly() {
const fMonth = document.getElementById('filter-month').value;
const fRight = document.getElementById('filter-right').value;
const fPtype = document.getElementById('filter-ptype').value;
const data = getMatchedData().filter(r =>
(!fMonth || r.disMonth === fMonth) &&
(!fRight || r.rightNorm === fRight) &&
(!fPtype || r.ptype === fPtype)
);
// Summary
const summaryMap = {};
data.forEach(r => {
const key = `${r.disMonth}||${r.rightNorm}`;
if (!summaryMap[key]) summaryMap[key] = {month:r.disMonth, right:r.rightNorm, count:0, total:0, patPay:0, rightPay:0, rep:0};
summaryMap[key].count++;
summaryMap[key].total += r.totalCost;
summaryMap[key].patPay += r.patPay;
summaryMap[key].rightPay += r.rightPay;
summaryMap[key].rep += r.totalRep;
});
const rows = Object.values(summaryMap).sort((a,b) => a.month.localeCompare(b.month));
document.getElementById('monthly-count-badge').textContent = rows.length + ' รายการ';
const totals = {count:0, total:0, patPay:0, rightPay:0, rep:0};
rows.forEach(r => { totals.count+=r.count; totals.total+=r.total; totals.patPay+=r.patPay; totals.rightPay+=r.rightPay; totals.rep+=r.rep; });
document.getElementById('monthly-summary-body').innerHTML = rows.length === 0
? '<tr><td colspan="9" style="text-align:center;padding:24px;color:#a0aec0">ไม่มีข้อมูล</td></tr>'
: rows.map(r => {
const diff = r.rep - r.rightPay;
const pct = r.rightPay > 0 ? (r.rep/r.rightPay*100).toFixed(1) : '0';
return `<tr>
<td>${monthLabel(r.month)}</td><td>${r.right}</td>
<td class="num">${fmtInt(r.count)}</td>
<td class="num">${fmt(r.total)}</td>
<td class="num">${fmt(r.patPay)}</td>
<td class="num">${fmt(r.rightPay)}</td>
<td class="num">${fmt(r.rep)}</td>
<td class="num" style="color:${diff<0?'#e74c3c':diff>0?'#27ae60':'inherit'}">${fmt(diff)}</td>
<td class="num">${pct}%</td>
</tr>`;
}).join('');
document.getElementById('monthly-summary-foot').innerHTML = `<tr class="tfoot">
<td colspan="2">รวมทั้งหมด</td>
<td class="num">${fmtInt(totals.count)}</td>
<td class="num">${fmt(totals.total)}</td>
<td class="num">${fmt(totals.patPay)}</td>
<td class="num">${fmt(totals.rightPay)}</td>
<td class="num">${fmt(totals.rep)}</td>
<td class="num">${fmt(totals.rep - totals.rightPay)}</td>
<td class="num">${totals.rightPay > 0 ? (totals.rep/totals.rightPay*100).toFixed(1) : '0'}%</td>
</tr>`;
// Patient list
document.getElementById('patient-list-badge').textContent = data.length + ' ราย';
document.getElementById('patient-list-body').innerHTML = data.length === 0
? '<tr><td colspan="11" style="text-align:center;padding:24px;color:#a0aec0">ไม่มีข้อมูล</td></tr>'
: data.map(r => {
const sb = r.status === 'matched' ? 'badge-matched' : r.status === 'partial' ? 'badge-partial' : r.status === 'deny' ? 'badge-deny' : 'badge-none';
const sl = r.status === 'matched' ? '✅ ครบ' : r.status === 'partial' ? '⚠️ บางส่วน' : r.status === 'deny' ? '🚫 DENY' : '❌ ไม่มี';
return `<tr>
<td>${r.hn}</td><td>${r.an}</td><td>${r.name}</td>
<td>${r.dischargeDate ? r.dischargeDate.toLocaleDateString('th-TH') : '-'}</td>
<td>${r.rightNorm}</td>
<td class="num">${fmt(r.patPay)}</td>
<td class="num">${fmt(r.rightPay)}</td>
<td class="num">${fmt(r.totalCost)}</td>
<td class="num">${fmt(r.totalRep)}</td>
<td><span class="${sb}">${sl}</span></td>
<td><button class="btn btn-danger btn-sm" onclick="deleteImpRecord(${r._storeIdx})" title="ลบรายการนี้">🗑️</button></td>
</tr>`;
}).join('');
}
// =============================================
// MATCHING VIEW
// =============================================
function renderMatching() {
const fMonth = document.getElementById('match-filter-month').value;
const fStatus = document.getElementById('match-filter-status').value;
const fRight = document.getElementById('match-filter-right').value;
const data = getMatchedData().filter(r =>
(!fMonth || r.disMonth === fMonth) &&
(!fStatus || r.status === fStatus) &&
(!fRight || r.rightNorm === fRight)
);
const matched = data.filter(r => r.status === 'matched').length;
const partial = data.filter(r => r.status === 'partial').length;
const none = data.filter(r => r.status === 'none').length;
const deny = data.filter(r => r.status === 'deny').length;
document.getElementById('m-matched').textContent = fmtInt(matched);
document.getElementById('m-partial').textContent = fmtInt(partial);
document.getElementById('m-none').textContent = fmtInt(none);
document.getElementById('m-deny').textContent = fmtInt(deny);
// Matching detail table
let tRightPay=0, tRep=0, tHc=0, tAe=0, tInst=0, tIp=0, tDmis=0, tDrug=0;
document.getElementById('matching-body').innerHTML = data.length === 0
? '<tr><td colspan="15" style="text-align:center;padding:24px;color:#a0aec0">ไม่มีข้อมูล</td></tr>'
: data.map(r => {
tRightPay+=r.rightPay; tRep+=r.totalRep; tHc+=r.totalHc; tAe+=r.totalAe;
tInst+=r.totalInst; tIp+=r.totalIp; tDmis+=r.totalDmis; tDrug+=r.totalDrug;
const sb = r.status === 'matched' ? 'badge-matched' : r.status === 'partial' ? 'badge-partial' : r.status === 'deny' ? 'badge-deny' : 'badge-none';
const sl = r.status === 'matched' ? '✅ ครบ' : r.status === 'partial' ? '⚠️ บางส่วน' : r.status === 'deny' ? '🚫 DENY' : '❌ ไม่มี';
return `<tr>
<td>${r.hn}</td><td>${r.name}</td>
<td>${r.dischargeDate ? r.dischargeDate.toLocaleDateString('th-TH') : '-'}</td>
<td>${r.rightNorm}</td>
<td style="font-size:11px;color:#4a5568">${r.repNos || '-'}</td>
<td class="num">${fmt(r.rightPay)}</td>
<td class="num">${fmt(r.totalRep)}</td>
<td class="num">${r.totalHc ? fmt(r.totalHc) : '-'}</td>
<td class="num">${r.totalAe ? fmt(r.totalAe) : '-'}</td>
<td class="num">${r.totalInst ? fmt(r.totalInst) : '-'}</td>
<td class="num">${r.totalIp ? fmt(r.totalIp) : '-'}</td>
<td class="num">${r.totalDmis ? fmt(r.totalDmis) : '-'}</td>
<td class="num">${r.totalDrug ? fmt(r.totalDrug) : '-'}</td>
<td class="num" style="color:${r.diff<0?'#e74c3c':r.diff>0?'#27ae60':'inherit'}">${fmt(r.diff)}</td>
<td><span class="${sb}">${sl}</span></td>
</tr>`;
}).join('');
document.getElementById('matching-foot').innerHTML = `<tr class="tfoot">
<td colspan="5">รวม</td>
<td class="num">${fmt(tRightPay)}</td>
<td class="num">${fmt(tRep)}</td>
<td class="num">${fmt(tHc)}</td>
<td class="num">${fmt(tAe)}</td>
<td class="num">${fmt(tInst)}</td>
<td class="num">${fmt(tIp)}</td>
<td class="num">${fmt(tDmis)}</td>
<td class="num">${fmt(tDrug)}</td>
<td class="num">${fmt(tRep - tRightPay)}</td>
<td></td>
</tr>`;
// Fund summary by right
const fundMap = {};
data.forEach(r => {
if (!fundMap[r.rightNorm]) fundMap[r.rightNorm] = {count:0, rightPay:0, rep:0, hc:0, ae:0, inst:0, ip:0, dmis:0, drug:0};
const f = fundMap[r.rightNorm];
f.count++; f.rightPay+=r.rightPay; f.rep+=r.totalRep;
f.hc+=r.totalHc; f.ae+=r.totalAe; f.inst+=r.totalInst;
f.ip+=r.totalIp; f.dmis+=r.totalDmis; f.drug+=r.totalDrug;
});
document.getElementById('fund-summary-body').innerHTML = Object.entries(fundMap).map(([right, f]) =>
`<tr>
<td>${right}</td>
<td class="num">${fmtInt(f.count)}</td>
<td class="num">${fmt(f.rightPay)}</td>
<td class="num">${fmt(f.rep)}</td>
<td class="num">${f.hc ? fmt(f.hc) : '-'}</td>
<td class="num">${f.ae ? fmt(f.ae) : '-'}</td>
<td class="num">${f.inst ? fmt(f.inst) : '-'}</td>
<td class="num">${f.ip ? fmt(f.ip) : '-'}</td>
<td class="num">${f.dmis ? fmt(f.dmis) : '-'}</td>
<td class="num">${f.drug ? fmt(f.drug) : '-'}</td>
<td class="num" style="color:${f.rep-f.rightPay<0?'#e74c3c':'#27ae60'}">${fmt(f.rep-f.rightPay)}</td>
</tr>`
).join('') || '<tr><td colspan="11" style="text-align:center;padding:16px;color:#a0aec0">ไม่มีข้อมูล</td></tr>';
// === กองทุนหลัก (Fund Code) breakdown ===
// Aggregate from eclaim records (only matched ones within current filter)
const matchedHns = new Set(data.filter(r => r.status !== 'none').map(r => r.hn));
const rightOfHn = {};
data.forEach(r => { rightOfHn[r.hn] = r.rightNorm; });
// key: rightNorm|fundMain|fundSub → {count, amount}
const codeMap = {};
store.eclaimRecords.forEach(rec => {
if (!matchedHns.has(rec.hn)) return;
if (!rec.fundMain) return;
const right = rightOfHn[rec.hn] || 'ไม่ระบุ';
// Apply filters
if (fMonth) {
const m = rec.serviceDate ? monthKey(rec.serviceDate) : (rec.admDate ? monthKey(rec.admDate) : '');
if (m && m !== fMonth) return;
}
if (fRight && right !== fRight) return;
const key = `${right}||${rec.fundMain}||${rec.fundSub || '-'}`;
if (!codeMap[key]) codeMap[key] = {right, fundMain: rec.fundMain, fundSub: rec.fundSub || '-', cat: getFundCategory(rec.fundMain, rec.fundSub), count: 0, amount: 0};
codeMap[key].count++;
codeMap[key].amount += rec.netComp;
});
const codeRows = Object.values(codeMap).sort((a,b) =>
a.right.localeCompare(b.right) || a.fundMain.localeCompare(b.fundMain) || a.fundSub.localeCompare(b.fundSub)
);
let codeTotal = 0;
document.getElementById('fund-code-body').innerHTML = codeRows.length === 0
? '<tr><td colspan="6" style="text-align:center;padding:16px;color:#a0aec0">ไม่มีข้อมูล</td></tr>'
: codeRows.map(r => {
codeTotal += r.amount;
const catLabel = FUND_LABELS[r.cat] || r.cat;
const catColor = r.cat==='hc'?'#2980b9':r.cat.startsWith('ae')?'#e67e22':r.cat==='dmis'?'#8e44ad':r.cat==='inst'?'#16a085':r.cat==='drug'?'#c0392b':r.cat==='ip'?'#27ae60':'#718096';
return `<tr>
<td>${r.right}</td>
<td><b style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:12px">${r.fundMain}</b></td>
<td><span style="color:${catColor};font-size:11px;font-weight:600">${catLabel}</span></td>
<td style="font-size:11px;color:#4a5568">${r.fundSub}</td>
<td class="num">${fmtInt(r.count)}</td>
<td class="num">${fmt(r.amount)}</td>
</tr>`;
}).join('');
document.getElementById('fund-code-foot').innerHTML = `<tr class="tfoot">
<td colspan="4">รวมทั้งหมด</td>
<td class="num">${fmtInt(codeRows.reduce((s,r)=>s+r.count,0))}</td>
<td class="num">${fmt(codeTotal)}</td>
</tr>`;
}
// =============================================
// ACCOUNTING REPORT
// =============================================
function renderAccounting() {
const fMonth = document.getElementById('acc-month').value;
const fRight = document.getElementById('acc-right').value;
const container = document.getElementById('accounting-report');
if (!fMonth) {
container.innerHTML = '<div style="text-align:center;padding:40px;color:#a0aec0;background:#fff;border-radius:10px">เลือกเดือนที่ต้องการออกรายงาน</div>';
return;
}
const data = getMatchedData().filter(r =>
r.disMonth === fMonth &&
(!fRight || r.rightNorm === fRight)
);
if (data.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:40px;color:#a0aec0;background:#fff;border-radius:10px">ไม่พบข้อมูลในเดือนที่เลือก</div>';
return;
}
const monthStr = monthLabel(fMonth);
const totalCost = data.reduce((s,r) => s + r.totalCost, 0);
const patPay = data.reduce((s,r) => s + r.patPay, 0);
const rightPay = data.reduce((s,r) => s + r.rightPay, 0);
const repPay = data.reduce((s,r) => s + r.totalRep, 0);
const diffAmt = repPay - rightPay;
const outstanding = rightPay - repPay;
// Group by right+ptype for accounting lines (แยก OPD/IPD เพื่อได้รหัสบัญชีที่ถูกต้อง)
const rightMap = {};
data.forEach(r => {
const key = r.rightNorm + '||' + r.ptype;
if (!rightMap[key]) rightMap[key] = {right:r.rightNorm, ptype:r.ptype, count:0, rightPay:0, repPay:0, hc:0, ae:0, inst:0, ip:0, dmis:0, drug:0};
const m = rightMap[key];
m.count++; m.rightPay+=r.rightPay; m.repPay+=r.totalRep;
m.hc+=r.totalHc; m.ae+=r.totalAe; m.inst+=r.totalInst;
m.ip+=r.totalIp; m.dmis+=r.totalDmis; m.drug+=r.totalDrug;
});
const rightRows = Object.values(rightMap).sort((a,b) => {
const ac = getAccountCode(a.right, a.ptype).code;
const bc = getAccountCode(b.right, b.ptype).code;
return ac.localeCompare(bc);
});
container.innerHTML = `
<div class="report-page" id="acc-printable">
<div class="report-title">โรงพยาบาลป่าพะยอม</div>
<div class="report-sub">รายงานลูกหนี้ค่ารักษาพยาบาล ประจำเดือน ${monthStr}</div>
<div class="report-sub" style="font-size:12px;color:#718096">ออกรายงานวันที่ ${new Date().toLocaleDateString('th-TH', {year:'numeric',month:'long',day:'numeric'})}</div>
<hr style="margin:12px 0;border-color:#e2e8f0">
<div class="report-section-title">ส่วนสูง — ยอดตั้งลูกหนี้ (เดบิต)</div>
<table style="width:100%;border-collapse:collapse;font-size:12px;margin-bottom:8px">
<thead><tr style="background:#f1f5f9">
<th style="padding:6px 10px;text-align:left;border-bottom:1px solid #e2e8f0">รหัสบัญชี</th>
<th style="padding:6px 10px;text-align:left;border-bottom:1px solid #e2e8f0">ชื่อบัญชี / สิทธิ</th>
<th style="padding:6px 10px;text-align:right;border-bottom:1px solid #e2e8f0">จำนวนราย</th>
<th style="padding:6px 10px;text-align:right;border-bottom:1px solid #e2e8f0">เดบิต (บาท)</th>
<th style="padding:6px 10px;text-align:right;border-bottom:1px solid #e2e8f0">เครดิต (บาท)</th>
</tr></thead>
<tbody>
${rightRows.map(r => {
const acc = getAccountCode(r.right, r.ptype);
return `<tr>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0;color:#1a5276;font-weight:600;font-family:monospace">${acc.code}</td>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0">${acc.name}</td>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0;text-align:right">${fmtInt(r.count)}</td>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0;text-align:right">${fmt(r.rightPay)}</td>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0;text-align:right">-</td>
</tr>`;}).join('')}
<tr style="font-weight:700;background:#f8fafc">
<td colspan="2" style="padding:7px 10px">รวมยอดตั้งลูกหนี้</td>
<td style="padding:7px 10px;text-align:right">${fmtInt(data.length)}</td>
<td style="padding:7px 10px;text-align:right;color:#1a5276">${fmt(rightPay)}</td>
<td style="padding:7px 10px;text-align:right">-</td>
</tr>
<tr><td colspan="2" style="padding:5px 10px;color:#718096;font-size:11px"> ค่าใช้จ่ายทั้งหมด</td>
<td style="padding:5px 10px;text-align:right;font-size:11px;color:#718096">${fmtInt(data.length)}</td>
<td style="padding:5px 10px;text-align:right;font-size:11px;color:#718096">${fmt(totalCost)}</td>
<td style="padding:5px 10px;text-align:right;font-size:11px;color:#718096">-</td>
</tr>
<tr><td colspan="2" style="padding:5px 10px;color:#718096;font-size:11px"> ผู้ป่วยชำระเอง</td>
<td style="padding:5px 10px;text-align:right;font-size:11px;color:#718096">-</td>
<td style="padding:5px 10px;text-align:right;font-size:11px;color:#718096">-</td>
<td style="padding:5px 10px;text-align:right;font-size:11px;color:#718096">${fmt(patPay)}</td>
</tr>
</tbody>
</table>
<div class="report-section-title">รายละเอียดการรับชำระ — แยกตามสิทธิ/ประเภทผู้ป่วย และหมวดกองทุน (เครดิต)</div>
<table style="width:100%;border-collapse:collapse;font-size:11.5px;margin-bottom:8px">
<thead><tr style="background:#f1f5f9">
<th style="padding:6px 10px;text-align:left;border-bottom:1px solid #e2e8f0;font-family:monospace">รหัสบัญชี GFMIS</th>
<th style="padding:6px 10px;text-align:left;border-bottom:1px solid #e2e8f0">สิทธิ / ประเภท</th>
<th style="padding:6px 10px;text-align:right;border-bottom:1px solid #e2e8f0">ชดเชยสุทธิ</th>
<th style="padding:6px 10px;text-align:right;border-bottom:1px solid #e2e8f0">HC ค่าใช้จ่ายสูง<br><span style="font-size:10px;font-weight:normal;font-family:monospace">218/219</span></th>
<th style="padding:6px 10px;text-align:right;border-bottom:1px solid #e2e8f0">AE ฉุกเฉิน<br><span style="font-size:10px;font-weight:normal;font-family:monospace">216/217</span></th>
<th style="padding:6px 10px;text-align:right;border-bottom:1px solid #e2e8f0">INST อวัยวะ</th>
<th style="padding:6px 10px;text-align:right;border-bottom:1px solid #e2e8f0">DMI โรคเฉพาะ<br><span style="font-size:10px;font-weight:normal;font-family:monospace">220/221</span></th>
<th style="padding:6px 10px;text-align:right;border-bottom:1px solid #e2e8f0">DRUG ยา</th>
</tr></thead>
<tbody>
${rightRows.map(r => {
const acc = getAccountCode(r.right, r.ptype);
return `<tr>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0;font-family:monospace;font-size:11px;color:#1a5276;font-weight:600">${acc.code}</td>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0">${r.right} <span style="font-size:10px;background:#e2e8f0;border-radius:3px;padding:1px 4px">${r.ptype||''}</span></td>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0;text-align:right">${fmt(r.repPay)}</td>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0;text-align:right">${r.hc ? fmt(r.hc) : '-'}</td>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0;text-align:right">${r.ae ? fmt(r.ae) : '-'}</td>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0;text-align:right">${r.inst ? fmt(r.inst) : '-'}</td>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0;text-align:right">${r.dmis ? fmt(r.dmis) : '-'}</td>
<td style="padding:5px 10px;border-bottom:1px dotted #e2e8f0;text-align:right">${r.drug ? fmt(r.drug) : '-'}</td>
</tr>`;}).join('')}
<tr style="font-weight:700;background:#f8fafc">
<td colspan="2" style="padding:7px 10px">รวมรับชำระทั้งหมด</td>
<td style="padding:7px 10px;text-align:right;color:#27ae60">${fmt(repPay)}</td>
<td colspan="5" style="padding:7px 10px"></td>
</tr>
</tbody>
</table>
${(() => {
// กองทุนหลัก detail breakdown from eclaim records
const matchedHns = new Set(data.filter(r => r.totalRep > 0).map(r => r.hn));
const codeAcc = {};
store.eclaimRecords.forEach(rec => {
if (!matchedHns.has(rec.hn) || !rec.fundMain) return;
const svcMonth = rec.serviceDate ? monthKey(rec.serviceDate) : (rec.admDate ? monthKey(rec.admDate) : '');
if (fMonth && svcMonth && svcMonth !== fMonth) return;
const cat = getFundCategory(rec.fundMain, rec.fundSub || '');
const key = rec.fundMain + '||' + (rec.fundSub || '-');
if (!codeAcc[key]) codeAcc[key] = {fundMain: rec.fundMain, fundSub: rec.fundSub||'-', cat, count:0, amount:0};
codeAcc[key].count++;
codeAcc[key].amount += rec.netComp;
});
const cRows = Object.values(codeAcc).sort((a,b) => a.fundMain.localeCompare(b.fundMain));
if (cRows.length === 0) return '';
// Map fund category → GFMIS account (UC เป็น default; ใช้ ptype จาก fundMain hint)
// hc/ae/dmis → CR (216/217); drug/inst → หมวดหลัก (201/202)
const fundCatToAcct = (cat, fundMain) => {
const isIP = /[_\-]?IP\b/i.test(fundMain);
const pt = isIP ? 'IPD' : 'OPD';
return getAccountCode('UC (บัตรทอง)', pt, cat);
};
return `<div class="report-section-title">รายละเอียดกองทุนหลัก (Fund Code) — รหัสบัญชี GFMIS ตามผังบัญชี 2560</div>
<table style="width:100%;border-collapse:collapse;font-size:11.5px;margin-bottom:8px">
<thead><tr style="background:#f1f5f9">
<th style="padding:5px 10px;text-align:left;border-bottom:1px solid #e2e8f0">รหัสบัญชี GFMIS</th>
<th style="padding:5px 10px;text-align:left;border-bottom:1px solid #e2e8f0">รหัสกองทุน REP</th>
<th style="padding:5px 10px;text-align:left;border-bottom:1px solid #e2e8f0">หมวดกองทุน</th>
<th style="padding:5px 10px;text-align:left;border-bottom:1px solid #e2e8f0">กองทุนย่อย</th>
<th style="padding:5px 10px;text-align:right;border-bottom:1px solid #e2e8f0">จำนวนรายการ</th>
<th style="padding:5px 10px;text-align:right;border-bottom:1px solid #e2e8f0">ยอดชดเชย (บาท)</th>
</tr></thead>
<tbody>
${cRows.map(r => {
const acct = fundCatToAcct(r.cat, r.fundMain);
return `<tr>
<td style="padding:4px 10px;border-bottom:1px dotted #e2e8f0;font-family:monospace;font-weight:600;color:#1a5276;font-size:11px">${acct.code}</td>
<td style="padding:4px 10px;border-bottom:1px dotted #e2e8f0;font-weight:600">${r.fundMain}</td>
<td style="padding:4px 10px;border-bottom:1px dotted #e2e8f0;color:#2980b9">${FUND_LABELS[r.cat]||r.cat}</td>
<td style="padding:4px 10px;border-bottom:1px dotted #e2e8f0;color:#4a5568;font-size:11px">${r.fundSub}</td>
<td style="padding:4px 10px;border-bottom:1px dotted #e2e8f0;text-align:right">${fmtInt(r.count)}</td>
<td style="padding:4px 10px;border-bottom:1px dotted #e2e8f0;text-align:right">${fmt(r.amount)}</td>
</tr>`;}).join('')}
<tr style="font-weight:700;background:#f8fafc">
<td colspan="4" style="padding:6px 10px">รวมทั้งหมด</td>
<td style="padding:6px 10px;text-align:right">${fmtInt(cRows.reduce((s,r)=>s+r.count,0))}</td>
<td style="padding:6px 10px;text-align:right;color:#27ae60">${fmt(cRows.reduce((s,r)=>s+r.amount,0))}</td>
</tr>
</tbody>
</table>`;
})()}
<div class="report-section-title">ส่วนต่ำ — สรุปลูกหนี้คงเหลือ</div>
<div style="font-size:13px;padding:4px 0">
<div class="report-line"><span>ยอดตั้งลูกหนี้ทั้งหมด (เดบิต)</span><span style="font-weight:700">${fmt(rightPay)} บาท</span></div>
<div class="report-line"><span>ยอดรับชำระจาก eclaim REP (เครดิต)</span><span style="font-weight:700;color:#27ae60">${fmt(repPay)} บาท</span></div>
<div class="report-line"><span>ผู้ป่วยชำระเอง</span><span>${fmt(patPay)} บาท</span></div>
<div class="report-total" style="margin-top:8px">
<span>ลูกหนี้คงเหลือ</span>
<span style="color:${outstanding > 0 ? '#e74c3c' : '#27ae60'}">${fmt(outstanding)} บาท</span>
</div>
<div class="report-line" style="font-size:11px;color:#718096"><span>ส่วนต่าง (REP เกิน/ขาด)</span><span style="color:${diffAmt>=0?'#27ae60':'#e74c3c'}">${fmt(diffAmt)} บาท</span></div>
</div>
<hr style="margin:16px 0;border-color:#e2e8f0">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;font-size:12px;text-align:center;margin-top:12px">
<div><div style="border-top:1px solid #2d3748;padding-top:6px;margin-top:40px">ผู้จัดทำ</div></div>
<div><div style="border-top:1px solid #2d3748;padding-top:6px;margin-top:40px">หัวหน้ากลุ่มงานการเงิน</div></div>
<div><div style="border-top:1px solid #2d3748;padding-top:6px;margin-top:40px">ผู้อำนวยการโรงพยาบาล</div></div>
</div>
</div>`;
}
function printAccounting() {
const el = document.getElementById('acc-printable');
if (!el) { alert('กรุณาเลือกเดือนก่อน'); return; }
const w = window.open('','_blank');
w.document.write(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>รายงานลูกหนี้</title>
<style>body{font-family:Arial,sans-serif;font-size:13px;padding:20px} table{width:100%;border-collapse:collapse} th,td{padding:5px 8px} @media print{button{display:none}}</style>
</head><body>${el.outerHTML}</body></html>`);
w.document.close();
w.focus();
setTimeout(() => w.print(), 500);
}
// =============================================
// EXPORTS
// =============================================
function exportMonthlyExcel() {
const fMonth = document.getElementById('filter-month').value;
const fRight = document.getElementById('filter-right').value;
const data = getMatchedData().filter(r =>
(!fMonth || r.disMonth === fMonth) &&
(!fRight || r.rightNorm === fRight)
);
const rows = data.map(r => ({
'เดือน': monthLabel(r.disMonth),
'HN': r.hn, 'AN': r.an, 'ชื่อ-สกุล': r.name,
'วันจำหน่าย': r.dischargeDate ? r.dischargeDate.toLocaleDateString('th-TH') : '',
'สิทธิ': r.rightNorm,
'ผู้ป่วยชำระ': r.patPay, 'สิทธิชำระ': r.rightPay,
'ค่าใช้จ่ายรวม': r.totalCost, 'REP รับ': r.totalRep,
'ส่วนต่าง': r.diff, 'สถานะ': r.status
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'ลูกหนี้รายเดือน');
XLSX.writeFile(wb, `ลูกหนี้_${fMonth || 'ทุกเดือน'}.xlsx`);
}
function exportMatchingExcel() {
const data = getMatchedData();
const rows = data.map(r => ({
'HN': r.hn, 'AN': r.an, 'ชื่อ-สกุล': r.name,
'วันจำหน่าย': r.dischargeDate ? r.dischargeDate.toLocaleDateString('th-TH') : '',
'สิทธิ': r.rightNorm, 'REP No.': r.repNos,
'สิทธิชำระ': r.rightPay, 'ชดเชยสุทธิ': r.totalRep,
'HC': r.totalHc, 'AE': r.totalAe, 'INST': r.totalInst,
'IP': r.totalIp, 'DMIS': r.totalDmis, 'DRUG': r.totalDrug,
'ส่วนต่าง': r.diff, 'สถานะ': r.status
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'จับคู่REP');
XLSX.writeFile(wb, 'matching_REP.xlsx');
}
function exportData() {
const json = JSON.stringify({
impRecords: store.impRecords.map(r => ({...r, admitDate: r.admitDate?.toISOString(), dischargeDate: r.dischargeDate?.toISOString()})),
eclaimRecords: store.eclaimRecords.map(r => ({...r, admDate: r.admDate?.toISOString(), disDate: r.disDate?.toISOString()})),
impFileCount: store.impFileCount,
eclaimFileCount: store.eclaimFileCount,
}, null, 2);
const blob = new Blob([json], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `ลูกหนี้_backup_${new Date().toLocaleDateString('th-TH').replace(/\//g,'-')}.json`;
a.click(); URL.revokeObjectURL(url);
log('💾 บันทึกข้อมูลสำเร็จ', 'ok');
}
function importData(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
try {
const data = JSON.parse(e.target.result);
store.impRecords = (data.impRecords || []).map(r => ({...r, admitDate: r.admitDate ? new Date(r.admitDate) : null, dischargeDate: r.dischargeDate ? new Date(r.dischargeDate) : null}));
store.eclaimRecords = (data.eclaimRecords || []).map(r => ({
...r,
admDate: r.admDate ? new Date(r.admDate) : null,
disDate: r.disDate ? new Date(r.disDate) : null,
serviceDate: r.serviceDate ? new Date(r.serviceDate) : null,
transferDate: r.transferDate ? new Date(r.transferDate) : null,
}));
store.impFileCount = data.impFileCount || 0;
store.eclaimFileCount = data.eclaimFileCount || 0;
refreshAll();
log(`📂 โหลดข้อมูลสำเร็จ — IMP: ${store.impRecords.length} ราย, eclaim: ${store.eclaimRecords.length} ราย`, 'ok');
} catch(err) {
log('❌ ไม่สามารถโหลดไฟล์: ' + err.message, 'err');
}
};
reader.readAsText(file, 'UTF-8');
input.value = '';
}
// =============================================
// LOCALSTORAGE PERSISTENCE
// =============================================
function saveToStorage() {
const tag = document.getElementById('storage-tag');
try {
const data = {
v: 2,
impRecords: store.impRecords.map(r => ({
...r,
admitDate: r.admitDate ? r.admitDate.toISOString() : null,
dischargeDate: r.dischargeDate ? r.dischargeDate.toISOString() : null,
repRecords: undefined, // ไม่ต้องบันทึก rebuilt dynamically
})),
eclaimRecords: store.eclaimRecords.map(r => ({
...r,
admDate: r.admDate ? r.admDate.toISOString() : null,
disDate: r.disDate ? r.disDate.toISOString() : null,
serviceDate: r.serviceDate ? r.serviceDate.toISOString() : null,
transferDate: r.transferDate ? r.transferDate.toISOString() : null,
})),
impFileCount: store.impFileCount,
eclaimFileCount: store.eclaimFileCount,
impFiles: store.impFiles,
eclaimFiles: store.eclaimFiles,
};
localStorage.setItem('hospital_ar_data_v2', JSON.stringify(data));
const ts = new Date().toLocaleTimeString('th-TH', {hour:'2-digit', minute:'2-digit'});
if (tag) { tag.textContent = `💾 บันทึก ${ts}`; tag.className = 'storage-tag saved'; }
} catch(e) {
if (tag) { tag.textContent = '⚠️ พื้นที่เต็ม'; tag.className = 'storage-tag warn'; }
console.warn('localStorage save failed:', e.message);
}
}
function loadFromStorage() {
const tag = document.getElementById('storage-tag');
try {
const json = localStorage.getItem('hospital_ar_data_v2');
if (!json) return;
const data = JSON.parse(json);
store.impRecords = (data.impRecords || []).map(r => ({
...r,
admitDate: r.admitDate ? new Date(r.admitDate) : null,
dischargeDate:r.dischargeDate? new Date(r.dischargeDate): null,
repRecords: [],
}));
store.eclaimRecords = (data.eclaimRecords || []).map(r => ({
...r,
admDate: r.admDate ? new Date(r.admDate) : null,
disDate: r.disDate ? new Date(r.disDate) : null,
serviceDate: r.serviceDate ? new Date(r.serviceDate) : null,
transferDate: r.transferDate ? new Date(r.transferDate) : null,
}));
store.impFileCount = data.impFileCount || 0;
store.eclaimFileCount = data.eclaimFileCount || 0;
store.impFiles = data.impFiles || [];
store.eclaimFiles = data.eclaimFiles || [];
if (store.impRecords.length > 0 || store.eclaimRecords.length > 0) {
if (tag) { tag.textContent = '✅ โหลดข้อมูลแล้ว'; tag.className = 'storage-tag saved'; }
log(`📂 โหลดข้อมูลจากเครื่อง — IMP: ${store.impRecords.length} ราย, eclaim: ${store.eclaimRecords.length} ราย`, 'ok');
refreshAllNoSave(); // ไม่ save ซ้ำตอน load
}
} catch(e) {
console.warn('localStorage load failed:', e.message);
}
}
// refreshAll without triggering save (for load-time use)
function refreshAllNoSave() {
updateImportStats();
populateFilters();
renderDashboard();
renderMonthly();
renderMatching();
renderAccounting();
renderEclaimTable();
renderImpFileList();
renderEclaimFileList();
}
// =============================================
// DELETE RECORD FUNCTIONS
// =============================================
function deleteImpRecord(storeIdx) {
const r = store.impRecords[storeIdx];
if (!r) return;
if (!confirm(`ลบรายการ IMP ?\nHN: ${r.hn} AN: ${r.an || '-'}\nชื่อ: ${r.name}\nสิทธิ: ${r.rightNorm}\nวันที่: ${r.dischargeDate ? r.dischargeDate.toLocaleDateString('th-TH') : '-'}`)) return;
store.impRecords.splice(storeIdx, 1);
log(`🗑️ ลบ IMP HN ${r.hn} (${r.name}) แล้ว`, 'warn');
refreshAll();
}
function deleteEclaimRecord(storeIdx) {
const r = store.eclaimRecords[storeIdx];
if (!r) return;
if (!confirm(`ลบรายการ eclaim ?\nREP No: ${r.repNo || '-'}\nHN: ${r.hn} AN: ${r.an || '-'}\nกองทุน: ${r.fundMain || '-'} / ${r.fundSub || '-'}\nยอด: ${fmt(r.netComp)} บาท`)) return;
store.eclaimRecords.splice(storeIdx, 1);
log(`🗑️ ลบ eclaim HN ${r.hn} REP ${r.repNo || '-'} แล้ว`, 'warn');
refreshAll();
}
// =============================================
// ECLAIM RECORDS TABLE
// =============================================
function renderEclaimTable() {
const searchEl = document.getElementById('eclaim-search');
const fundEl = document.getElementById('eclaim-fund-filter');
if (!searchEl) return;
const search = searchEl.value.toLowerCase().trim();
const fundFilter = (fundEl?.value || '').toUpperCase().trim();
const LIMIT = 500;
let records = store.eclaimRecords.map((r, i) => ({...r, _storeIdx: i}));
if (search) records = records.filter(r =>
(r.hn || '').toLowerCase().includes(search) ||
(r.repNo || '').toLowerCase().includes(search) ||
(r.an || '').toLowerCase().includes(search)
);
if (fundFilter) records = records.filter(r => (r.fundMain || '').toUpperCase().includes(fundFilter));
const total = records.length;
const shown = records.slice(0, LIMIT);
const badge = document.getElementById('eclaim-records-badge');
if (badge) badge.textContent = fmtInt(total) + ' รายการ';
const body = document.getElementById('eclaim-records-body');
if (!body) return;
body.innerHTML = shown.length === 0
? '<tr><td colspan="10" style="text-align:center;padding:16px;color:#a0aec0">ไม่มีข้อมูล</td></tr>'
: shown.map(r => {
const svcDate = r.serviceDate || r.admDate || r.disDate || null;
const fname = (r.sourceFile || '').replace(/.*[/\\]/, '').substring(0, 22);
return `<tr>
<td style="color:#718096;font-size:11px">${r._storeIdx + 1}</td>
<td style="font-size:11px">${r.repNo || '-'}</td>
<td><b>${r.hn || '-'}</b></td>
<td style="font-size:11px">${r.an || '-'}</td>
<td style="font-size:11px">${svcDate ? svcDate.toLocaleDateString('th-TH') : '-'}</td>
<td><b style="font-size:11px;background:#f1f5f9;padding:1px 5px;border-radius:3px">${r.fundMain || '-'}</b></td>
<td style="font-size:11px;color:#4a5568">${r.fundSub || '-'}</td>
<td class="num">${fmt(r.netComp)}</td>
<td style="font-size:10px;color:#718096;max-width:120px;overflow:hidden;text-overflow:ellipsis" title="${r.sourceFile||''}">${fname}</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteEclaimRecord(${r._storeIdx})" title="ลบรายการนี้">🗑️</button></td>
</tr>`;
}).join('');
const moreEl = document.getElementById('eclaim-records-more');
if (moreEl) moreEl.textContent = total > LIMIT ? `แสดง ${LIMIT} จาก ${fmtInt(total)} รายการ (ใช้ตัวกรองเพื่อค้นหาเพิ่มเติม)` : '';
}
// =============================================
// TAB NAVIGATION
// =============================================
function showTab(name) {
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.getElementById(`panel-${name}`).classList.add('active');
event.target.classList.add('active');
if (name === 'dashboard') renderDashboard();
if (name === 'import') renderEclaimTable();
if (name === 'monthly') renderMonthly();
if (name === 'matching') renderMatching();
if (name === 'accounting')renderAccounting();
}
// Init
document.addEventListener('DOMContentLoaded', () => {
log('🏥 ระบบบริหารลูกหนี้ โรงพยาบาลป่าพะยอม พร้อมใช้งาน', 'ok');
log('📌 วิธีใช้: นำเข้าไฟล์ IMP ก่อน จากนั้นนำเข้า eclaim REP เพื่อจับคู่', 'ok');
log('💾 ข้อมูลบันทึกอัตโนมัติในเครื่อง (localStorage) — ไม่สูญหายเมื่อปิด browser', 'ok');
loadFromStorage();
});
</script>
</body>
</html>