403Webshell
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 :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /home/paphayomho/domains/paphayomhospital.go.th/private_html//chronicfu-seq-audit.html
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CHRONICFU / SEQ Audit | HDC 43 Files</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Sarabun:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
  :root { --green:#08724f; --green2:#10a06f; --bg:#eff7f3; --panel:#fff; --line:#d2e7dd; --text:#183328; --muted:#58766a; --blue:#0ea5e9; --orange:#f59e0b; --red:#dc2626; --soft:#f8fcfa; --shadow:0 4px 18px rgba(8,114,79,.11); }
  * { box-sizing:border-box }
  body { margin:0; font-family:Sarabun, sans-serif; color:var(--text); background:var(--bg); font-size:14px; }
  .topbar { min-height:66px; padding:13px 22px; display:flex; justify-content:space-between; align-items:center; gap:16px; color:#fff; background:linear-gradient(120deg,#064831,#08724f 65%,#12a778); box-shadow:var(--shadow) }
  h1 { margin:0; font-size:22px; line-height:1.25 }
  .sub { opacity:.9; margin-top:3px; font-size:13px }
  .badge { padding:8px 13px; border-radius:999px; background:rgba(255,255,255,.17); font-weight:700 }
  .nav { display:flex; gap:4px; overflow-x:auto; background:#fff; border-bottom:1px solid var(--line); padding:0 18px; position:sticky; top:0; z-index:10 }
  .tab { border:0; background:transparent; padding:15px 14px; font:700 14px Sarabun; color:#28493d; cursor:pointer; border-bottom:3px solid transparent; white-space:nowrap }
  .tab.active { color:var(--green); border-bottom-color:var(--green) }
  main { max-width:1540px; margin:0 auto; padding:22px }
  .page { display:none } .page.active { display:block }
  .card { background:var(--panel); border:1px solid var(--line); border-radius:8px; box-shadow:var(--shadow); padding:18px; margin-bottom:18px }
  .title { display:flex; align-items:center; gap:10px; color:#063f2c; font-weight:800; font-size:18px; margin-bottom:14px }
  .icon { width:34px; height:34px; display:grid; place-items:center; color:#fff; background:var(--green); border-radius:7px }
  .drop { min-height:170px; border:2px dashed #9dccb8; border-radius:8px; background:#fbfffd; display:flex; flex-direction:column; justify-content:center; align-items:center; padding:24px; text-align:center; cursor:pointer }
  .drop.drag,.drop:hover { border-color:var(--green); background:#e7f6f0 }
  .drop strong { font-size:19px; color:var(--green) } .drop span { color:var(--muted); margin-top:7px }
  .controls { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin:12px 0 }
  input,select { min-height:38px; padding:8px 10px; border:1px solid #bddbce; border-radius:6px; background:#fff; font-family:Sarabun, sans-serif }
  .btn { border:0; border-radius:7px; padding:9px 13px; color:#fff; background:var(--green); font:700 14px Sarabun; cursor:pointer }
  .btn.outline { color:var(--green); background:#fff; border:1px solid var(--green) } .btn.warn { background:var(--orange) } .btn.blue { background:#0d6efd }
  .alert { border-left:5px solid var(--blue); border-radius:8px; padding:12px 14px; background:#eaf5ff; color:#123c69; margin:12px 0 }
  .alert.warn { border-left-color:var(--orange); background:#fff8e7; color:#784500 }
  .alert.danger { border-left-color:var(--red); background:#fff1f1; color:#7f1d1d }
  .kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:12px; margin:14px 0 }
  .kpi { background:var(--soft); border:1px solid var(--line); border-top:4px solid var(--green); border-radius:8px; padding:15px }
  .kpi.blue { border-top-color:var(--blue) } .kpi.yellow { border-top-color:var(--orange) } .kpi.red { border-top-color:var(--red) }
  .n { font-size:28px; font-weight:900; line-height:1.1; color:#063f2c } .kpi.red .n { color:var(--red) } .kpi.yellow .n { color:#a35d00 }
  .l { color:var(--muted); margin-top:5px }
  .tbl { overflow:auto; border:1px solid var(--line); border-radius:8px; max-height:540px; background:#fff }
  table { border-collapse:collapse; width:100%; min-width:980px }
  th { position:sticky; top:0; background:var(--green); color:#fff; text-align:left; padding:10px; white-space:nowrap; z-index:1 }
  td { padding:9px 10px; border-bottom:1px solid var(--line); vertical-align:top } tr:nth-child(even) td { background:#f8fcfa }
  .mono { font-family:"IBM Plex Mono", Consolas, monospace }
  .pill { display:inline-flex; padding:3px 8px; border-radius:999px; font-size:12px; font-weight:800; background:#e6f5ef; color:var(--green); white-space:nowrap }
  .pill.red { background:#ffe6e6; color:var(--red) } .pill.yellow { background:#fff4d6; color:#9a5b00 } .pill.blue { background:#e8f3ff; color:#0b5cad }
  .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px }
  .empty { padding:24px; text-align:center; color:var(--muted) }
  @media (max-width:900px){ main{padding:14px}.grid2{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column} }
</style>
</head>
<body>
<div class="topbar">
  <div>
    <h1>CHRONICFU / SEQ Audit</h1>
    <div class="sub">หา visit F/U ที่ HIS ไม่ generate CHRONICFU, SEQ ว่าง/ไม่ตรง SERVICE และ export จุดแก้ให้ programmer HIS</div>
  </div>
  <div class="badge" id="hcodeBadge">HCODE: -</div>
</div>
<div class="nav">
  <button class="tab active" id="tab-upload" onclick="showPage('upload')">Upload</button>
  <button class="tab" id="tab-summary" onclick="showPage('summary')">Summary</button>
  <button class="tab" id="tab-missing" onclick="showPage('missing')">Missing FU</button>
  <button class="tab" id="tab-seq" onclick="showPage('seq')">SEQ Problems</button>
  <button class="tab" id="tab-draft" onclick="showPage('draft')">Draft CHRONICFU</button>
  <button class="tab" id="tab-register" onclick="showPage('register')">CHRONIC Register</button>
  <button class="tab" id="tab-export" onclick="showPage('export')">Export</button>
</div>
<main>
  <section class="page active" id="page-upload">
    <div class="card">
      <div class="title"><span class="icon">1</span> อัปโหลด ZIP 43 แฟ้ม</div>
      <div class="drop" id="drop" onclick="document.getElementById('fileInput').click()">
        <strong>ลากไฟล์ F43 ZIP มาวาง หรือคลิกเพื่อเลือกไฟล์</strong>
        <span>ต้องมีอย่างน้อย PERSON, SERVICE, CHRONIC, CHRONICFU; ถ้ามี LABFU/DIAGNOSIS จะช่วยชี้ว่ามี visit จริงชัดขึ้น</span>
      </div>
      <input type="file" id="fileInput" accept=".zip" style="display:none" onchange="handleZip(this.files[0])">
      <div id="uploadInfo"></div>
    </div>
    <div class="grid2">
      <div class="card">
        <div class="title"><span class="icon">?</span> แอปนี้ตอบอะไร</div>
        <div class="alert">ถ้า `SERVICE` มี visit และมี CHRONIC DM/HT active แต่ไม่มี `CHRONICFU` ที่ `PID+SEQ+DATE_SERV` ตรงกัน แอปจะจัดเป็น visit ที่ควรตรวจว่าบันทึก FU หรือระบบส่งออกขาด</div>
      </div>
      <div class="card">
        <div class="title"><span class="icon">!</span> ข้อควรระวัง</div>
        <div class="alert warn">Draft CHRONICFU เป็นไฟล์ช่วยตรวจและส่งให้ programmer/HIS ไม่ใช่หลักฐานบริการจริง ถ้าจะเติมค่าปกติ เช่น FOOT/RETINA ต้องยืนยันว่ามีการตรวจจริงในวันนั้น</div>
      </div>
    </div>
  </section>

  <section class="page" id="page-summary">
    <div class="card">
      <div class="title"><span class="icon">S</span> Summary</div>
      <div class="controls">
        <span>ปีงบ:</span><input id="fy" type="number" value="2569" style="width:100px" onchange="runAudit()">
        <span>กลุ่มโรค:</span>
        <select id="diseaseFilter" onchange="runAudit()">
          <option value="dmht">DM + HT</option>
          <option value="dm">DM เท่านั้น</option>
          <option value="ht">HT เท่านั้น</option>
        </select>
        <button class="btn" onclick="runAudit()">คำนวณใหม่</button>
      </div>
      <div id="summaryKpis"></div>
      <div id="summaryText"></div>
    </div>
  </section>

  <section class="page" id="page-missing">
    <div class="card">
      <div class="title"><span class="icon">M</span> SERVICE มี แต่ CHRONICFU ไม่ออก</div>
      <div class="controls">
        <input id="missingSearch" placeholder="ค้นหา PID/CID/ชื่อ/วันที่/เหตุผล" oninput="renderMissing()">
        <select id="missingLevel" onchange="renderMissing()">
          <option value="all">ทุกระดับ</option>
          <option value="high">High: มีหลักฐาน visit NCD/LAB/Diag</option>
          <option value="medium">Medium</option>
        </select>
      </div>
      <div id="missingTable"></div>
    </div>
  </section>

  <section class="page" id="page-seq">
    <div class="card">
      <div class="title"><span class="icon">Q</span> CHRONICFU ที่ SEQ ว่างหรือไม่ตรง SERVICE</div>
      <div class="controls">
        <input id="seqSearch" placeholder="ค้นหา PID/CID/SEQ/เหตุผล" oninput="renderSeq()">
      </div>
      <div id="seqTable"></div>
    </div>
  </section>

  <section class="page" id="page-draft">
    <div class="card">
      <div class="title"><span class="icon">D</span> Draft CHRONICFU สำหรับตรวจ/ส่ง HIS</div>
      <div class="controls">
        <span>วิธีเติมค่า:</span>
        <select id="draftMode" onchange="renderDraft()">
          <option value="latest_fill">ดึงค่าล่าสุด + auto-fill ช่องว่างตามเพศ</option>
          <option value="latest">ดึงค่าล่าสุดของ PID ถ้ามี</option>
          <option value="normal">auto-fill default ตามเพศ</option>
          <option value="blank">เว้นค่าวัดผลไว้ ให้ HIS เติมจริง</option>
        </select>
        <span>หญิง W/H/Waist:</span><input id="defFemaleVitals" value="50/155/32" style="width:105px" onchange="renderDraft()">
        <span>ชาย W/H/Waist:</span><input id="defMaleVitals" value="60/160/36" style="width:105px" onchange="renderDraft()">
        <span>BP:</span><input id="defBp" value="120/80" style="width:80px" onchange="renderDraft()">
        <span>FOOT default:</span><input id="defFoot" value="9" style="width:70px" onchange="renderDraft()">
        <span>RETINA default:</span><input id="defRetina" value="9" style="width:70px" onchange="renderDraft()">
        <span>SMOKING:</span><input id="defSmoking" value="1" style="width:70px" onchange="renderDraft()">
        <span>ALCOHOL:</span><input id="defAlcohol" value="1" style="width:70px" onchange="renderDraft()">
      </div>
      <div class="alert warn">ถ้าเลือกค่า default ปกติ แอปจะเติมเฉพาะค่าที่ใช้เป็นรหัสสถานะ เช่น FOOT/RETINA/SMOKING/ALCOHOL ส่วนค่าน้ำหนัก ส่วนสูง รอบเอว BP จะเว้นไว้ ยกเว้นเลือกดึงค่าล่าสุดและคนไข้เคยมีค่าเดิม</div>
      <div id="draftTable"></div>
    </div>
  </section>

  <section class="page" id="page-register">
    <div class="card">
      <div class="title"><span class="icon">R</span> คัดกรองผู้ควรขึ้นทะเบียน CHRONIC</div>
      <div class="alert warn">ใช้สำหรับตรวจสอบก่อนขึ้นทะเบียนจริง: ไม่ใช้ diagnosis ครั้งเดียวเป็นหลักฐานพอ แต่ให้คะแนนจาก 1) ยาเกี่ยวข้อง 2) LAB ติดตามซ้ำ 3) นัดต่อเนื่อง</div>
      <div class="controls">
        <span>ระดับหลักฐาน:</span>
        <select id="regLevel" onchange="renderRegister()">
          <option value="recommended">แนะนำขึ้นทะเบียน</option>
          <option value="strong">หลักฐานสูง</option>
          <option value="medium">กลางขึ้นไป</option>
          <option value="all">ทั้งหมดที่มี diagnosis แต่ยังไม่ขึ้น</option>
        </select>
        <span>โรค:</span>
        <select id="regDisease" onchange="renderRegister()">
          <option value="all">DM + HT</option>
          <option value="DM">DM</option>
          <option value="HT">HT</option>
        </select>
        <input id="regSearch" placeholder="ค้นหา PID/CID/ชื่อ/ยา/เหตุผล" oninput="renderRegister()">
      </div>
      <div id="registerAudit"></div>
    </div>
  </section>

  <section class="page" id="page-export">
    <div class="card">
      <div class="title"><span class="icon">E</span> Export</div>
      <div class="controls">
        <button class="btn blue" onclick="exportWorkbook()">Export Excel ให้ HIS Programmer</button>
        <button class="btn" onclick="exportFixed43Zip()">Export 43 ZIP เติม CHRONICFU/CHRONIC</button>
        <label><input type="checkbox" id="includeChronicAdds" onchange="renderExport()"> เพิ่ม CHRONIC เฉพาะ candidate หลักฐานสูง</label>
        <button class="btn outline" onclick="copyBrief()">Copy สรุปปัญหา</button>
      </div>
      <div id="exportInfo"></div>
    </div>
  </section>
</main>

<script>
const state = { files:{}, hcode:'', active:'upload', audit:{ missing:[], seqProblems:[], incomplete:[], drafts:[], register:[], chronicPatients:0, serviceVisits:0, matchedFu:0 } };
const cfFields = ['WEIGHT','HEIGHT','WAIST_CM','SBP','DBP','FOOT','RETINA','SMOKING','ALCOHOL','PROVIDER','CHRONICFUPLACE'];
const normalFields = ['WEIGHT','HEIGHT','WAIST_CM','SBP','DBP','FOOT','RETINA','SMOKING','ALCOHOL'];
const chronicDrugKeywords = {
  DM: ['metformin','glipizide','gliclazide','glimepiride','insulin','pioglitazone','dapagliflozin','empagliflozin','sitagliptin','linagliptin','vildagliptin','alogliptin'],
  HT: ['amlodipine','manidipine','nifedipine','enalapril','losartan','valsartan','telmisartan','atenolol','metoprolol','propranolol','carvedilol','hctz','hydrochlorothiazide','diltiazem','verapamil','doxazosin','hydralazine']
};
const chronicLabCodes = {
  DM: new Set(['0531601','0531001','0531002','0531101','0531102','0541402','0541602','0546602','0581902','0583001']),
  HT: new Set(['0581902','0621201','0541402','0541602','0546602','0583001'])
};

function $(id){ return document.getElementById(id); }
function compact(v){ return String(v ?? '').trim(); }
function esc(v){ return String(v ?? '').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
function fmt(n){ return Number(n || 0).toLocaleString('th-TH'); }
function normName(n){ return n.split('/').pop().toLowerCase(); }
function ymd(v){ return compact(v).slice(0,8); }
function isDate8(v){ return /^[12]\d{7}$/.test(compact(v)); }
function isDm(code){ return /^E1[0-4]/i.test(compact(code)); }
function isHt(code){ return /^I1[0-5]/i.test(compact(code)); }
function isNcd(code){ return isDm(code) || isHt(code); }
function today8(){ const d = new Date(); return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`; }
function currentThaiFiscalYear(){ const d = new Date(); const y = d.getFullYear() + 543; return d.getMonth()+1 >= 10 ? y+1 : y; }
function fiscalRange(fy){ fy = Number(fy || currentThaiFiscalYear()); return { start:String(fy-544)+'1001', end:String(fy-543)+'0930' }; }

function parseTxt(text){
  text = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
  const lines = text.split('\n').filter(x => x.trim() !== '');
  if (!lines.length) return { header:[], rows:[] };
  return { header:lines[0].split('|').map(x => x.trim().toUpperCase()), rows:lines.slice(1).map(line => line.split('|')) };
}
function col(file, name){ return state.files[file]?.header.indexOf(name.toUpperCase()) ?? -1; }
function val(file, row, name){ const i = col(file, name); return i >= 0 ? compact(row[i]) : ''; }
function personMap(){
  const m = new Map(), f = state.files['person.txt'];
  if (!f) return m;
  f.rows.forEach((row,i) => {
    const pid = val('person.txt', row, 'PID');
    if (pid) m.set(pid, { row, rowNum:i+2, pid, cid:val('person.txt', row, 'CID'), name:`${val('person.txt', row, 'NAME') || val('person.txt', row, 'FNAME')} ${val('person.txt', row, 'LNAME')}`.trim(), sex:val('person.txt', row, 'SEX'), typearea:val('person.txt', row, 'TYPEAREA'), discharge:val('person.txt', row, 'DISCHARGE'), nation:val('person.txt', row, 'NATION') });
  });
  return m;
}
function showPage(page){
  state.active = page;
  document.querySelectorAll('.page').forEach(x => x.classList.remove('active'));
  document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
  $('page-' + page).classList.add('active');
  $('tab-' + page).classList.add('active');
  renderActive();
}
const drop = $('drop');
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('drag'); });
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('drag'); if (e.dataTransfer.files[0]) handleZip(e.dataTransfer.files[0]); });

async function handleZip(file){
  if (!file) return;
  $('uploadInfo').innerHTML = '<div class="alert">กำลังอ่าน ZIP และตรวจ CHRONICFU/SEQ...</div>';
  const zip = await JSZip.loadAsync(await file.arrayBuffer());
  state.files = {};
  for (const entry of Object.values(zip.files).filter(f => !f.dir && f.name.toLowerCase().endsWith('.txt'))) {
    state.files[normName(entry.name)] = parseTxt(await entry.async('string'));
  }
  const pf = state.files['person.txt'];
  state.hcode = pf?.rows?.length ? val('person.txt', pf.rows[0], 'HOSPCODE') : '';
  $('hcodeBadge').textContent = 'HCODE: ' + (state.hcode || '-');
  if ($('fy')) $('fy').value = $('fy').value || currentThaiFiscalYear();
  runAudit();
  $('uploadInfo').innerHTML = `<div class="alert">อ่านไฟล์สำเร็จ ${fmt(Object.keys(state.files).length)} แฟ้ม, PERSON ${fmt(pf?.rows.length || 0)} records</div>`;
  showPage('summary');
}

function activeChronicPatients(){
  const f = state.files['chronic.txt'];
  if (!f) return new Map();
  const mode = $('diseaseFilter')?.value || 'dmht';
  const out = new Map();
  f.rows.forEach((row,i) => {
    const pid = val('chronic.txt', row, 'PID');
    const code = val('chronic.txt', row, 'CHRONIC');
    const typedisch = val('chronic.txt', row, 'TYPEDISCH');
    if (!pid || typedisch !== '03') return;
    const dm = isDm(code), ht = isHt(code);
    if ((mode === 'dm' && !dm) || (mode === 'ht' && !ht) || (mode === 'dmht' && !dm && !ht)) return;
    if (!out.has(pid)) out.set(pid, { pid, diseases:new Set(), rows:[] });
    if (dm) out.get(pid).diseases.add('DM');
    if (ht) out.get(pid).diseases.add('HT');
    out.get(pid).rows.push({ rowNum:i+2, code, date:val('chronic.txt', row, 'DATE_DIAG') });
  });
  return out;
}
function chronicGroup(code){
  if (isDm(code)) return 'DM';
  if (isHt(code)) return 'HT';
  return '';
}
function existingChronicGroupKeys(){
  const keys = new Set();
  const f = state.files['chronic.txt'];
  if (!f) return keys;
  f.rows.forEach(row => {
    const pid = val('chronic.txt', row, 'PID');
    const group = chronicGroup(val('chronic.txt', row, 'CHRONIC'));
    if (pid && group) keys.add(`${pid}|${group}`);
  });
  return keys;
}
function chronicCandidatesFromDiagnosis(){
  const people = personMap();
  const existing = existingChronicGroupKeys();
  const latest = new Map();
  const fy = Number($('fy')?.value || currentThaiFiscalYear());
  const { start, end } = fiscalRange(fy);
  const collect = (file, codeField, dateField) => {
    const f = state.files[file];
    if (!f) return;
    f.rows.forEach((row,i) => {
      const pid = val(file, row, 'PID');
      const code = val(file, row, codeField);
      const group = chronicGroup(code);
      if (!pid || !group || existing.has(`${pid}|${group}`)) return;
      const date = ymd(val(file, row, dateField)) || today8();
      if (date < start || date > end) return;
      const key = `${pid}|${group}`;
      const cur = latest.get(key);
      if (!cur || date > cur.date) {
        const p = people.get(pid);
        latest.set(key, {
          key, pid, cid:p?.cid || val(file, row, 'CID'), group, chronic:code,
          date, source:file, sourceRow:i+2
        });
      }
    });
  };
  collect('diagnosis_opd.txt', 'DIAGCODE', 'DATE_SERV');
  collect('diagnosis_ipd.txt', 'DIAGCODE', 'DATETIME_ADMIT');
  return [...latest.values()];
}
function diagnosisEvidenceByPidGroup(){
  const fy = Number($('fy')?.value || currentThaiFiscalYear());
  const { start, end } = fiscalRange(fy);
  const map = new Map();
  const add = (file, row, rowNum, code, date) => {
    const pid = val(file, row, 'PID');
    const group = chronicGroup(code);
    if (!pid || !group || date < start || date > end) return;
    const key = `${pid}|${group}`;
    if (!map.has(key)) map.set(key, { count:0, dates:new Set(), codes:new Set(), latest:'', rows:[] });
    const x = map.get(key);
    x.count++;
    x.dates.add(date);
    x.codes.add(code);
    if (date > x.latest) x.latest = date;
    x.rows.push(`${file} row ${rowNum} ${date} ${code}`);
  };
  ['diagnosis_opd.txt','diagnosis_ipd.txt'].forEach(file => {
    const f = state.files[file];
    if (!f) return;
    f.rows.forEach((row,i) => {
      const code = val(file, row, file === 'diagnosis_ipd.txt' ? 'DIAGCODE' : 'DIAGCODE');
      const date = ymd(val(file, row, file === 'diagnosis_ipd.txt' ? 'DATETIME_ADMIT' : 'DATE_SERV'));
      add(file, row, i+2, code, date);
    });
  });
  return map;
}
function drugEvidence(pid, group){
  const fy = Number($('fy')?.value || currentThaiFiscalYear());
  const { start, end } = fiscalRange(fy);
  const kws = chronicDrugKeywords[group] || [];
  const out = { count:0, dates:new Set(), names:new Set(), rows:[] };
  const scan = (file, dateField) => {
    const f = state.files[file];
    if (!f) return;
    f.rows.forEach((row,i) => {
      if (val(file, row, 'PID') !== pid) return;
      const date = ymd(val(file, row, dateField));
      if (date < start || date > end) return;
      const name = val(file, row, 'DNAME').toLowerCase();
      const did = val(file, row, 'DIDSTD');
      if (!kws.some(k => name.includes(k))) return;
      out.count++;
      out.dates.add(date);
      out.names.add(val(file, row, 'DNAME') || did);
      out.rows.push(`${file} row ${i+2} ${date} ${val(file, row, 'DNAME') || did}`);
    });
  };
  scan('drug_opd.txt', 'DATE_SERV');
  scan('drug_ipd.txt', 'DATETIME_ADMIT');
  return out;
}
function labFollowEvidence(pid, group){
  const fy = Number($('fy')?.value || currentThaiFiscalYear());
  const { start, end } = fiscalRange(fy);
  const f = state.files['labfu.txt'];
  const codes = chronicLabCodes[group] || new Set();
  const out = { count:0, dates:new Set(), tests:new Set(), rows:[] };
  if (!f) return out;
  f.rows.forEach((row,i) => {
    if (val('labfu.txt', row, 'PID') !== pid) return;
    const date = val('labfu.txt', row, 'DATE_SERV');
    const test = val('labfu.txt', row, 'LABTEST');
    if (date < start || date > end || !codes.has(test)) return;
    out.count++;
    out.dates.add(date);
    out.tests.add(test);
    out.rows.push(`labfu row ${i+2} ${date} ${test}=${val('labfu.txt', row, 'LABRESULT')}`);
  });
  return out;
}
function appointmentEvidence(pid, group){
  const fy = Number($('fy')?.value || currentThaiFiscalYear());
  const { start, end } = fiscalRange(fy);
  const f = state.files['appointment.txt'];
  const out = { count:0, dates:new Set(), diagDates:new Set(), rows:[] };
  if (!f) return out;
  f.rows.forEach((row,i) => {
    if (val('appointment.txt', row, 'PID') !== pid) return;
    const apdate = val('appointment.txt', row, 'APDATE');
    const dateServ = val('appointment.txt', row, 'DATE_SERV');
    const date = apdate || dateServ;
    if (date < start || date > end) return;
    const apdiag = val('appointment.txt', row, 'APDIAG');
    const apGroup = chronicGroup(apdiag);
    if (apGroup && apGroup !== group) return;
    out.count++;
    out.dates.add(date);
    if (apGroup === group) out.diagDates.add(date);
    out.rows.push(`appointment row ${i+2} ${date} ${apdiag || ''}`);
  });
  return out;
}
function buildRegisterCandidates(){
  if (!hasData()) return [];
  const people = personMap();
  const diagMap = diagnosisEvidenceByPidGroup();
  return chronicCandidatesFromDiagnosis().map(c => {
    const p = people.get(c.pid);
    const diag = diagMap.get(c.key) || { count:1, dates:new Set([c.date]), codes:new Set([c.chronic]), latest:c.date, rows:[] };
    const drug = drugEvidence(c.pid, c.group);
    const lab = labFollowEvidence(c.pid, c.group);
    const appt = appointmentEvidence(c.pid, c.group);
    let score = 0;
    score += Math.min(10, drug.count * 3) + (drug.dates.size >= 2 ? 4 : drug.dates.size ? 2 : 0);
    score += lab.dates.size >= 2 ? 4 : lab.dates.size === 1 ? 2 : 0;
    score += appt.dates.size >= 2 ? 3 : appt.dates.size === 1 ? 1 : 0;
    score += diag.dates.size >= 2 ? 2 : 0;
    let level = 'ต่ำ';
    if (drug.count >= 2 || (drug.count >= 1 && (lab.dates.size >= 1 || appt.dates.size >= 1)) || score >= 8) level = 'สูง';
    else if (drug.count >= 1 || lab.dates.size >= 2 || appt.dates.size >= 2 || score >= 4) level = 'กลาง';
    const recommend = level === 'สูง' ? 'แนะนำขึ้นทะเบียน' : level === 'กลาง' ? 'ส่งเจ้าหน้าที่ตรวจสอบ' : 'ยังไม่ควรขึ้นจากหลักฐานนี้อย่างเดียว';
    return {
      LEVEL:level, RECOMMEND:recommend, SCORE:score, GROUP:c.group, PID:c.pid, CID:p?.cid || c.cid || '', NAME:p?.name || '', SEX:p?.sex || '',
      TYPEAREA:p?.typearea || '', DISCHARGE:p?.discharge || '', NATION:p?.nation || '',
      CHRONIC_SUGGEST:c.chronic, DATE_DIAG:c.date, DIAG_COUNT:diag.count, DIAG_DATES:diag.dates.size,
      DRUG_COUNT:drug.count, DRUG_DATES:drug.dates.size, DRUG_NAMES:[...drug.names].slice(0,5).join(' | '),
      LAB_COUNT:lab.count, LAB_DATES:lab.dates.size, LAB_TESTS:[...lab.tests].join(', '),
      APPOINT_COUNT:appt.count, APPOINT_DATES:appt.dates.size,
      EVIDENCE:[...drug.rows.slice(0,3), ...lab.rows.slice(0,2), ...appt.rows.slice(0,2), ...diag.rows.slice(0,2)].join(' || '),
      SOURCE_DIAG:`${c.source} row ${c.sourceRow}`
    };
  }).sort((a,b) => b.SCORE - a.SCORE || a.GROUP.localeCompare(b.GROUP));
}
function serviceRowsByPid(fy){
  const f = state.files['service.txt'];
  const out = new Map();
  if (!f) return out;
  const { start, end } = fiscalRange(fy);
  f.rows.forEach((row,i) => {
    const pid = val('service.txt', row, 'PID');
    const date = val('service.txt', row, 'DATE_SERV');
    if (!pid || date < start || date > end) return;
    if (!out.has(pid)) out.set(pid, []);
    out.get(pid).push({ file:'service.txt', row, rowNum:i+2, pid, cid:val('service.txt', row, 'CID'), seq:val('service.txt', row, 'SEQ'), date, provider:val('service.txt', row, 'PROVIDER'), clinic:val('service.txt', row, 'CLINIC'), chiefcomp:val('service.txt', row, 'CHIEFCOMP') });
  });
  return out;
}
function chronicFuRowsByPid(fy){
  const f = state.files['chronicfu.txt'];
  const out = new Map();
  if (!f) return out;
  const { start, end } = fiscalRange(fy);
  f.rows.forEach((row,i) => {
    const pid = val('chronicfu.txt', row, 'PID');
    const date = val('chronicfu.txt', row, 'DATE_SERV');
    if (!pid || date < start || date > end) return;
    if (!out.has(pid)) out.set(pid, []);
    out.get(pid).push({ file:'chronicfu.txt', row, rowNum:i+2, pid, cid:val('chronicfu.txt', row, 'CID'), seq:val('chronicfu.txt', row, 'SEQ'), date });
  });
  return out;
}
function relatedEvidence(pid, seq, date){
  const pieces = [];
  const dopd = state.files['diagnosis_opd.txt'];
  if (dopd) {
    const rows = dopd.rows.filter(r => val('diagnosis_opd.txt', r, 'PID') === pid && val('diagnosis_opd.txt', r, 'DATE_SERV') === date && (!seq || val('diagnosis_opd.txt', r, 'SEQ') === seq));
    const ncd = rows.map(r => val('diagnosis_opd.txt', r, 'DIAGCODE')).filter(isNcd);
    if (ncd.length) pieces.push('DIAG_NCD=' + [...new Set(ncd)].join(','));
  }
  const lab = state.files['labfu.txt'];
  if (lab) {
    const rows = lab.rows.filter(r => val('labfu.txt', r, 'PID') === pid && val('labfu.txt', r, 'DATE_SERV') === date && (!seq || val('labfu.txt', r, 'SEQ') === seq));
    const codes = rows.map(r => val('labfu.txt', r, 'LABTEST')).filter(Boolean);
    if (codes.length) pieces.push('LABFU=' + [...new Set(codes)].slice(0,8).join(','));
  }
  return pieces.join(' | ');
}
function latestFuBefore(pid, date){
  const f = state.files['chronicfu.txt'];
  if (!f) return null;
  let best = null;
  f.rows.forEach((row,i) => {
    if (val('chronicfu.txt', row, 'PID') !== pid) return;
    const d = val('chronicfu.txt', row, 'DATE_SERV');
    if (!isDate8(d) || d > date) return;
    if (!best || d > best.date) best = { row, rowNum:i+2, date:d };
  });
  return best;
}
function runAudit(){
  if (!Object.keys(state.files).length) return;
  const fy = Number($('fy')?.value || currentThaiFiscalYear());
  const people = personMap();
  const chronic = activeChronicPatients();
  const services = serviceRowsByPid(fy);
  const fus = chronicFuRowsByPid(fy);
  const missing = [];
  let serviceVisits = 0, matchedFu = 0;
  chronic.forEach((info,pid) => {
    const person = people.get(pid);
    const serviceRows = services.get(pid) || [];
    const fuRows = fus.get(pid) || [];
    const fuKeys = new Set(fuRows.map(x => `${x.seq}|${x.date}`));
    serviceRows.forEach(s => {
      serviceVisits++;
      const exact = s.seq && fuKeys.has(`${s.seq}|${s.date}`);
      const sameDateFu = fuRows.some(f => f.date === s.date);
      if (exact) { matchedFu++; return; }
      const evidence = relatedEvidence(pid, s.seq, s.date);
      const level = evidence || sameDateFu ? 'high' : 'medium';
      missing.push({
        level, pid, cid:person?.cid || s.cid || '', name:person?.name || '', disease:[...info.diseases].join('/'),
        typearea:person?.typearea || '', discharge:person?.discharge || '', serviceRow:s.rowNum, seq:s.seq, date:s.date,
        clinic:s.clinic, provider:s.provider, sameDateFu:sameDateFu ? 'Y' : 'N', evidence,
        reason: sameDateFu ? 'มี CHRONICFU วันเดียวกัน แต่ SEQ ไม่ตรง SERVICE' : 'มี SERVICE แต่ไม่มี CHRONICFU ที่ PID+SEQ+DATE_SERV ตรงกัน',
        hisFix: s.seq ? 'ตรวจ mapping หน้าจอ F/U ให้ generate CHRONICFU ด้วย SEQ นี้' : 'SERVICE.SEQ ว่าง ต้องให้ HIS generate SEQ ก่อนสร้าง CHRONICFU'
      });
    });
  });
  const seqProblems = [];
  const incomplete = [];
  const serviceKey = new Set();
  services.forEach(rows => rows.forEach(s => serviceKey.add(`${s.pid}|${s.seq}|${s.date}`)));
  fus.forEach(rows => rows.forEach(fu => {
    if (!fu.seq) seqProblems.push(problemFromFu(fu, 'CHRONICFU.SEQ ว่าง', 'ให้ HIS ผูก CHRONICFU กับ visit/SERVICE.SEQ ที่ถูกต้อง'));
    else if (!serviceKey.has(`${fu.pid}|${fu.seq}|${fu.date}`)) seqProblems.push(problemFromFu(fu, 'CHRONICFU.SEQ ไม่พบใน SERVICE วันเดียวกัน', 'ตรวจว่า SEQ/DATE_SERV/PID ตรงกับ SERVICE หรือไม่'));
    if (chronic.has(fu.pid)) {
      const missingFields = normalFields.filter(field => !val('chronicfu.txt', fu.row, field));
      if (missingFields.length) {
        const p = people.get(fu.pid);
        incomplete.push({
          level: missingFields.some(f => ['WEIGHT','HEIGHT','WAIST_CM','SBP','DBP'].includes(f)) ? 'high' : 'medium',
          rowNum:fu.rowNum, pid:fu.pid, cid:p?.cid || fu.cid || '', name:p?.name || '', sex:p?.sex || '',
          seq:fu.seq, date:fu.date, missingFields:missingFields.join(', '),
          reason:`CHRONICFU มีแล้ว แต่ช่องสำคัญว่าง: ${missingFields.join(', ')}`,
          hisFix:'เติมค่าที่ขาดจาก vital sign/หน้าจอ F/U หรือให้ HIS map field เหล่านี้ออก CHRONICFU'
        });
      }
    }
  }));
  state.audit = { missing, seqProblems, incomplete, drafts:[], chronicPatients:chronic.size, serviceVisits, matchedFu };
  state.audit.drafts = buildDraftRows();
  state.audit.register = buildRegisterCandidates();
  renderActive();
}
function problemFromFu(fu, reason, hisFix){
  const people = personMap(), p = people.get(fu.pid);
  return { level:'high', rowNum:fu.rowNum, pid:fu.pid, cid:p?.cid || fu.cid || '', name:p?.name || '', seq:fu.seq, date:fu.date, reason, hisFix };
}
function draftHeader(){
  const f = state.files['chronicfu.txt'];
  const base = f?.header?.length ? f.header : [];
  const required = ['HOSPCODE','PID','SEQ','DATE_SERV','WEIGHT','HEIGHT','WAIST_CM','SBP','DBP','FOOT','RETINA','SMOKING','ALCOHOL','PROVIDER','D_UPDATE','CHRONICFUPLACE','CID'];
  return [...new Set([...base, ...required])];
}
function parseTriple(id, fallback){
  const raw = ($ ? ($(id)?.value || '') : '').split('/').map(x => x.trim());
  return {
    weight: raw[0] || fallback.weight,
    height: raw[1] || fallback.height,
    waist: raw[2] || fallback.waist
  };
}
function parseBp(){
  const raw = ($('defBp')?.value || '120/80').split('/').map(x => x.trim());
  return { sbp: raw[0] || '120', dbp: raw[1] || '80' };
}
function normalDefaultsForSex(sex){
  const female = parseTriple('defFemaleVitals', { weight:'50', height:'155', waist:'32' });
  const male = parseTriple('defMaleVitals', { weight:'60', height:'160', waist:'36' });
  const vitals = sex === '2' ? female : male;
  const bp = parseBp();
  return {
    WEIGHT:vitals.weight, HEIGHT:vitals.height, WAIST_CM:vitals.waist,
    SBP:bp.sbp, DBP:bp.dbp,
    FOOT:$('defFoot')?.value || '9',
    RETINA:$('defRetina')?.value || '9',
    SMOKING:$('defSmoking')?.value || '1',
    ALCOHOL:$('defAlcohol')?.value || '1'
  };
}
function fillMissingNormal(obj, sex, header){
  const defaults = normalDefaultsForSex(sex);
  const filled = [];
  Object.entries(defaults).forEach(([field, value]) => {
    if (!compact(obj[field]) && compact(value)) {
      obj[field] = value;
      filled.push(`${field}=${value}`);
    }
  });
  return filled;
}
function buildDraftRows(){
  const mode = $('draftMode')?.value || 'latest_fill';
  const header = draftHeader();
  const people = personMap();
  const missingDrafts = state.audit.missing.map(x => {
    const latest = latestFuBefore(x.pid, x.date);
    const person = people.get(x.pid);
    const obj = {};
    header.forEach(h => obj[h] = '');
    obj.HOSPCODE = state.hcode;
    obj.PID = x.pid;
    obj.SEQ = x.seq;
    obj.DATE_SERV = x.date;
    obj.CID = x.cid;
    obj.PROVIDER = x.provider || (latest ? val('chronicfu.txt', latest.row, 'PROVIDER') : '');
    obj.D_UPDATE = today8();
    obj.CHRONICFUPLACE = state.hcode;
    obj.SEX = person?.sex || '';
    let filled = [];
    if ((mode === 'latest' || mode === 'latest_fill') && latest) {
      cfFields.forEach(f => { if (header.includes(f)) obj[f] = val('chronicfu.txt', latest.row, f); });
      obj.DRAFT_SOURCE = `latest CHRONICFU row ${latest.rowNum} date ${latest.date}`;
      if (mode === 'latest_fill') {
        filled = fillMissingNormal(obj, person?.sex || '', header);
        obj.DRAFT_SOURCE += filled.length ? ` + autofill missing (${filled.join(', ')})` : ' + no missing normal fields';
      }
    } else if (mode === 'latest_fill' && !latest) {
      filled = fillMissingNormal(obj, person?.sex || '', header);
      obj.DRAFT_SOURCE = `no latest CHRONICFU; autofill default by sex ${person?.sex === '2' ? 'female' : 'male/unknown'} (${filled.join(', ')})`;
    } else if (mode === 'normal') {
      filled = fillMissingNormal(obj, person?.sex || '', header);
      obj.DRAFT_SOURCE = `default by sex ${person?.sex === '2' ? 'female' : 'male/unknown'} (${filled.join(', ')})`;
    } else {
      obj.DRAFT_SOURCE = 'blank measured fields; HIS must fill from real visit';
    }
    obj.AUTOFILL_FIELDS = filled.join(', ');
    obj.AUDIT_REASON = x.reason;
    obj.HIS_FIX = x.hisFix;
    return obj;
  });
  const incompleteDrafts = state.audit.incomplete.map(x => {
    const person = people.get(x.pid);
    const source = state.files['chronicfu.txt']?.rows[x.rowNum - 2];
    const obj = {};
    header.forEach(h => obj[h] = source ? val('chronicfu.txt', source, h) : '');
    obj.CID = obj.CID || x.cid;
    obj.SEX = person?.sex || x.sex || '';
    const filled = mode === 'blank' ? [] : fillMissingNormal(obj, person?.sex || x.sex || '', header);
    obj.DRAFT_SOURCE = source
      ? `existing CHRONICFU row ${x.rowNum}; autofill missing only (${filled.join(', ') || 'none'})`
      : `existing CHRONICFU row ${x.rowNum} not found`;
    obj.AUTOFILL_FIELDS = filled.join(', ');
    obj.AUDIT_REASON = x.reason;
    obj.HIS_FIX = x.hisFix;
    return obj;
  });
  return [...missingDrafts, ...incompleteDrafts];
}

function renderActive(){
  if (state.active === 'summary') renderSummary();
  if (state.active === 'missing') renderMissing();
  if (state.active === 'seq') renderSeq();
  if (state.active === 'draft') renderDraft();
  if (state.active === 'register') renderRegister();
  if (state.active === 'export') renderExport();
}
function needUpload(){ return '<div class="empty">กรุณาอัปโหลด ZIP 43 แฟ้มก่อน</div>'; }
function hasData(){ return Object.keys(state.files).length > 0; }
function match(row,q){ if (!q) return true; return Object.values(row).some(v => String(v ?? '').toLowerCase().includes(q)); }
function table(rows, cols, empty='ไม่พบรายการ'){
  if (!rows.length) return `<div class="empty">${empty}</div>`;
  return `<div class="tbl"><table><thead><tr>${cols.map(c => `<th>${esc(c.label)}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr>${cols.map(c => `<td>${c.html ? c.html(r) : esc(r[c.key])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
}
function renderSummary(){
  if (!hasData()) { $('summaryKpis').innerHTML = needUpload(); $('summaryText').innerHTML = ''; return; }
  const high = state.audit.missing.filter(x => x.level === 'high').length + state.audit.seqProblems.length + state.audit.incomplete.filter(x => x.level === 'high').length;
  $('summaryKpis').innerHTML = `<div class="kpis">
    <div class="kpi"><div class="n">${fmt(state.audit.chronicPatients)}</div><div class="l">CHRONIC active ในกลุ่มที่เลือก</div></div>
    <div class="kpi blue"><div class="n">${fmt(state.audit.serviceVisits)}</div><div class="l">SERVICE visit ในปีงบ</div></div>
    <div class="kpi"><div class="n">${fmt(state.audit.matchedFu)}</div><div class="l">มี CHRONICFU ตรง SEQ</div></div>
    <div class="kpi red"><div class="n">${fmt(state.audit.missing.length)}</div><div class="l">SERVICE มี แต่ FU ไม่ตรง/ไม่ออก</div></div>
    <div class="kpi red"><div class="n">${fmt(state.audit.seqProblems.length)}</div><div class="l">CHRONICFU SEQ ผิด</div></div>
    <div class="kpi yellow"><div class="n">${fmt(state.audit.incomplete.length)}</div><div class="l">CHRONICFU มีแต่ vital/normal field ว่าง</div></div>
    <div class="kpi yellow"><div class="n">${fmt(state.audit.drafts.length)}</div><div class="l">Draft rows ที่ export ได้</div></div>
  </div>`;
  $('summaryText').innerHTML = high ? `<div class="alert danger">พบจุดที่ควรส่ง HIS programmer ตรวจ ${fmt(high)} รายการ โดยเฉพาะ visit ที่มี LAB/DIAG แต่ไม่เกิด CHRONICFU</div>` : '<div class="alert">ไม่พบปัญหา CHRONICFU/SEQ หลักจากเงื่อนไขนี้</div>';
}
function renderMissing(){
  if (!hasData()) { $('missingTable').innerHTML = needUpload(); return; }
  const q = compact($('missingSearch').value).toLowerCase();
  const lv = $('missingLevel').value;
  const rows = state.audit.missing.filter(r => (lv === 'all' || r.level === lv) && match(r,q)).slice(0,1800);
  $('missingTable').innerHTML = table(rows, [
    {label:'ระดับ', html:r => `<span class="pill ${r.level === 'high' ? 'red' : 'yellow'}">${esc(r.level)}</span>`},
    {label:'PID', html:r => `<span class="mono">${esc(r.pid)}</span>`}, {label:'CID', html:r => `<span class="mono">${esc(r.cid)}</span>`}, {label:'ชื่อ', key:'name'},
    {label:'โรค', key:'disease'}, {label:'TYPEAREA', html:r => `<span class="mono">${esc(r.typearea)}</span>`}, {label:'SERVICE row', html:r => `<span class="mono">${esc(r.serviceRow)}</span>`},
    {label:'SEQ', html:r => `<span class="mono">${esc(r.seq || 'ว่าง')}</span>`}, {label:'DATE_SERV', html:r => `<span class="mono">${esc(r.date)}</span>`},
    {label:'Clinic', key:'clinic'}, {label:'Evidence', key:'evidence'}, {label:'เหตุผล', key:'reason'}, {label:'จุดให้ HIS แก้', key:'hisFix'}
  ]);
}
function renderSeq(){
  if (!hasData()) { $('seqTable').innerHTML = needUpload(); return; }
  const q = compact($('seqSearch').value).toLowerCase();
  const rows = state.audit.seqProblems.filter(r => match(r,q)).slice(0,1800);
  const incompleteRows = state.audit.incomplete.filter(r => match(r,q)).slice(0,1800);
  $('seqTable').innerHTML = `
  <div class="alert warn">ส่วนบนคือปัญหา SEQ; ส่วนล่างคือ CHRONICFU ที่มีแถวแล้วแต่ค่าน้ำหนัก/ส่วนสูง/รอบเอว/BP หรือค่า normal อื่นว่าง</div>
  ${table(rows, [
    {label:'ระดับ', html:r => '<span class="pill red">high</span>'}, {label:'CHRONICFU row', html:r => `<span class="mono">${esc(r.rowNum)}</span>`},
    {label:'PID', html:r => `<span class="mono">${esc(r.pid)}</span>`}, {label:'CID', html:r => `<span class="mono">${esc(r.cid)}</span>`}, {label:'ชื่อ', key:'name'},
    {label:'SEQ', html:r => `<span class="mono">${esc(r.seq || 'ว่าง')}</span>`}, {label:'DATE_SERV', html:r => `<span class="mono">${esc(r.date)}</span>`}, {label:'เหตุผล', key:'reason'}, {label:'จุดให้ HIS แก้', key:'hisFix'}
  ])}
  <div style="height:14px"></div>
  ${table(incompleteRows, [
    {label:'ระดับ', html:r => `<span class="pill ${r.level === 'high' ? 'red' : 'yellow'}">${esc(r.level)}</span>`},
    {label:'CHRONICFU row', html:r => `<span class="mono">${esc(r.rowNum)}</span>`},
    {label:'PID', html:r => `<span class="mono">${esc(r.pid)}</span>`}, {label:'CID', html:r => `<span class="mono">${esc(r.cid)}</span>`}, {label:'SEX', html:r => `<span class="mono">${esc(r.sex || '-')}</span>`}, {label:'ชื่อ', key:'name'},
    {label:'SEQ', html:r => `<span class="mono">${esc(r.seq || 'ว่าง')}</span>`}, {label:'DATE_SERV', html:r => `<span class="mono">${esc(r.date)}</span>`}, {label:'ช่องที่ขาด', key:'missingFields'}, {label:'จุดให้ HIS แก้', key:'hisFix'}
  ], 'ไม่พบ CHRONICFU ที่ค่าว่างตามเงื่อนไข')}`;
}
function renderDraft(){
  if (!hasData()) { $('draftTable').innerHTML = needUpload(); return; }
  state.audit.drafts = buildDraftRows();
  $('draftTable').innerHTML = table(state.audit.drafts.slice(0,1200), [
    {label:'PID', html:r => `<span class="mono">${esc(r.PID)}</span>`}, {label:'CID', html:r => `<span class="mono">${esc(r.CID)}</span>`}, {label:'SEX', html:r => `<span class="mono">${esc(r.SEX || '-')}</span>`}, {label:'SEQ', html:r => `<span class="mono">${esc(r.SEQ || 'ว่าง')}</span>`},
    {label:'DATE_SERV', html:r => `<span class="mono">${esc(r.DATE_SERV)}</span>`}, {label:'WEIGHT', key:'WEIGHT'}, {label:'HEIGHT', key:'HEIGHT'}, {label:'WAIST', key:'WAIST_CM'}, {label:'SBP', key:'SBP'}, {label:'DBP', key:'DBP'},
    {label:'FOOT', key:'FOOT'}, {label:'RETINA', key:'RETINA'}, {label:'SMOKING', key:'SMOKING'}, {label:'ALCOHOL', key:'ALCOHOL'}, {label:'PROVIDER', key:'PROVIDER'}, {label:'Autofill', key:'AUTOFILL_FIELDS'}, {label:'Draft source', key:'DRAFT_SOURCE'}, {label:'HIS fix', key:'HIS_FIX'}
  ]);
}
function registerFilterRows(){
  const q = compact($('regSearch')?.value || '').toLowerCase();
  const level = $('regLevel')?.value || 'recommended';
  const disease = $('regDisease')?.value || 'all';
  return (state.audit.register || []).filter(r => {
    if (disease !== 'all' && r.GROUP !== disease) return false;
    if (level === 'recommended' && r.RECOMMEND !== 'แนะนำขึ้นทะเบียน') return false;
    if (level === 'strong' && r.LEVEL !== 'สูง') return false;
    if (level === 'medium' && !['สูง','กลาง'].includes(r.LEVEL)) return false;
    return match(r, q);
  });
}
function renderRegister(){
  if (!hasData()) { $('registerAudit').innerHTML = needUpload(); return; }
  if (!state.audit.register?.length) state.audit.register = buildRegisterCandidates();
  const rows = registerFilterRows();
  const high = rows.filter(x => x.LEVEL === 'สูง').length;
  const med = rows.filter(x => x.LEVEL === 'กลาง').length;
  const low = rows.filter(x => x.LEVEL === 'ต่ำ').length;
  $('registerAudit').innerHTML = `
    <div class="kpis">
      <div class="kpi red"><div class="n">${fmt(high)}</div><div class="l">หลักฐานสูง</div></div>
      <div class="kpi yellow"><div class="n">${fmt(med)}</div><div class="l">หลักฐานกลาง</div></div>
      <div class="kpi"><div class="n">${fmt(low)}</div><div class="l">หลักฐานต่ำ</div></div>
      <div class="kpi blue"><div class="n">${fmt(rows.length)}</div><div class="l">รายการตาม filter</div></div>
    </div>
    ${table(rows.slice(0,1800), [
      {label:'ระดับ', html:r => `<span class="pill ${r.LEVEL === 'สูง' ? 'red' : r.LEVEL === 'กลาง' ? 'yellow' : 'blue'}">${esc(r.LEVEL)}</span>`},
      {label:'คำแนะนำ', key:'RECOMMEND'}, {label:'Score', html:r => `<span class="mono">${esc(r.SCORE)}</span>`}, {label:'โรค', key:'GROUP'},
      {label:'PID', html:r => `<span class="mono">${esc(r.PID)}</span>`}, {label:'CID', html:r => `<span class="mono">${esc(r.CID)}</span>`}, {label:'ชื่อ', key:'NAME'},
      {label:'CHRONIC', key:'CHRONIC_SUGGEST'}, {label:'DATE_DIAG', html:r => `<span class="mono">${esc(r.DATE_DIAG)}</span>`},
      {label:'ยา', html:r => `<strong>${fmt(r.DRUG_COUNT)}</strong> ครั้ง / ${fmt(r.DRUG_DATES)} วัน<br>${esc(r.DRUG_NAMES)}`},
      {label:'LAB', html:r => `<strong>${fmt(r.LAB_COUNT)}</strong> ครั้ง / ${fmt(r.LAB_DATES)} วัน<br>${esc(r.LAB_TESTS)}`},
      {label:'นัด', html:r => `<strong>${fmt(r.APPOINT_COUNT)}</strong> ครั้ง / ${fmt(r.APPOINT_DATES)} วัน`},
      {label:'Diag', html:r => `<strong>${fmt(r.DIAG_COUNT)}</strong> ครั้ง / ${fmt(r.DIAG_DATES)} วัน`},
      {label:'Evidence', key:'EVIDENCE'}
    ], 'ไม่พบ candidate ตาม filter นี้')}`;
}
function renderExport(){
  const includeChronic = $('includeChronicAdds')?.checked;
  const chronicAdds = hasData() && includeChronic ? buildRegisterCandidates().filter(x => x.LEVEL === 'สูง').length : 0;
  $('exportInfo').innerHTML = hasData()
    ? `<div class="alert ${includeChronic ? 'warn' : ''}">พร้อม export: Missing FU ${fmt(state.audit.missing.length)}, SEQ problems ${fmt(state.audit.seqProblems.length)}, Incomplete FU ${fmt(state.audit.incomplete.length)}, Draft rows ${fmt(state.audit.drafts.length)}${includeChronic ? `, จะเพิ่ม CHRONIC หลักฐานสูง ${fmt(chronicAdds)} รายการ` : ', ไม่เพิ่ม CHRONIC ใน ZIP'}</div>`
    : needUpload();
}
function exportWorkbook(){
  if (!hasData()) { alert('กรุณาอัปโหลด ZIP ก่อน'); return; }
  state.audit.drafts = buildDraftRows();
  const wb = XLSX.utils.book_new();
  const add = (name, rows) => XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(rows), name);
  add('Missing_CHRONICFU', state.audit.missing);
  add('CHRONICFU_SEQ_problem', state.audit.seqProblems);
  add('CHRONICFU_incomplete', state.audit.incomplete);
  add('Draft_CHRONICFU', state.audit.drafts);
  state.audit.register = buildRegisterCandidates();
  add('CHRONIC_register_review', state.audit.register);
  add('HIS_fix_list', [
    ...state.audit.missing.map(x => ({ PRIORITY:x.level === 'high' ? 'High' : 'Medium', PID:x.pid, CID:x.cid, DATE_SERV:x.date, SEQ:x.seq, SERVICE_ROW:x.serviceRow, PROBLEM:x.reason, EVIDENCE:x.evidence, HIS_FIX:x.hisFix })),
    ...state.audit.seqProblems.map(x => ({ PRIORITY:'High', PID:x.pid, CID:x.cid, DATE_SERV:x.date, SEQ:x.seq, CHRONICFU_ROW:x.rowNum, PROBLEM:x.reason, EVIDENCE:'', HIS_FIX:x.hisFix })),
    ...state.audit.incomplete.map(x => ({ PRIORITY:x.level === 'high' ? 'High' : 'Medium', PID:x.pid, CID:x.cid, DATE_SERV:x.date, SEQ:x.seq, CHRONICFU_ROW:x.rowNum, PROBLEM:x.reason, MISSING_FIELDS:x.missingFields, EVIDENCE:'', HIS_FIX:x.hisFix }))
  ]);
  XLSX.writeFile(wb, `CHRONICFU_SEQ_Audit_${state.hcode || 'HDC'}_${$('fy')?.value || currentThaiFiscalYear()}.xlsx`);
}
function cloneRows(rows){
  return rows.map(row => row.slice());
}
function setRowValueByHeader(header, row, field, value){
  const i = header.indexOf(field);
  if (i >= 0) row[i] = value ?? '';
}
function objectToRowByHeader(header, obj){
  return header.map(h => obj[h] ?? '');
}
function serializeTxtFile(fileObj, rows){
  const header = fileObj.header;
  const lines = [header.join('|'), ...rows.map(row => header.map((_,i) => row[i] ?? '').join('|'))];
  return lines.join('\r\n') + '\r\n';
}
function chronicFuKeyFromObj(obj){
  return `${obj.PID || ''}|${obj.SEQ || ''}|${obj.DATE_SERV || ''}`;
}
function applyChronicFuFixes(rows){
  const file = state.files['chronicfu.txt'];
  if (!file) return { added:0, updated:0, skipped:0 };
  state.audit.drafts = buildDraftRows();
  const header = file.header;
  const keyToIndex = new Map();
  rows.forEach((row,i) => {
    const obj = {};
    header.forEach((h,idx) => obj[h] = row[idx] ?? '');
    keyToIndex.set(chronicFuKeyFromObj(obj), i);
  });
  let added = 0, updated = 0, skipped = 0;
  state.audit.drafts.forEach(draft => {
    if (!draft.PID || !draft.DATE_SERV || !draft.SEQ) { skipped++; return; }
    const key = chronicFuKeyFromObj(draft);
    const idx = keyToIndex.get(key);
    if (idx >= 0) {
      const row = rows[idx];
      normalFields.forEach(field => {
        if (header.includes(field) && !compact(row[header.indexOf(field)]) && compact(draft[field])) {
          setRowValueByHeader(header, row, field, draft[field]);
        }
      });
      ['PROVIDER','CHRONICFUPLACE','CID','D_UPDATE'].forEach(field => {
        if (header.includes(field) && !compact(row[header.indexOf(field)]) && compact(draft[field])) {
          setRowValueByHeader(header, row, field, draft[field]);
        }
      });
      updated++;
    } else {
      const out = {};
      header.forEach(h => out[h] = draft[h] ?? '');
      rows.push(objectToRowByHeader(header, out));
      keyToIndex.set(key, rows.length - 1);
      added++;
    }
  });
  return { added, updated, skipped };
}
function buildChronicRowObject(candidate, header){
  const obj = {};
  header.forEach(h => obj[h] = '');
  obj.HOSPCODE = state.hcode;
  obj.PID = candidate.pid;
  obj.CID = candidate.cid || '';
  obj.DATE_DIAG = candidate.date || today8();
  obj.CHRONIC = candidate.chronic;
  obj.HOSP_DX = state.hcode;
  obj.HOSP_RX = state.hcode;
  obj.DATE_DISCH = '';
  obj.TYPEDISCH = '03';
  obj.D_UPDATE = today8();
  return obj;
}
function applyChronicAdds(rows){
  const file = state.files['chronic.txt'];
  if (!file) return { added:0, skipped:0 };
  const header = file.header;
  const existing = new Set();
  rows.forEach(row => {
    const pid = row[header.indexOf('PID')] ?? '';
    const code = row[header.indexOf('CHRONIC')] ?? '';
    const group = chronicGroup(code);
    if (pid && group) existing.add(`${pid}|${group}`);
  });
  let added = 0, skipped = 0;
  buildRegisterCandidates().filter(x => x.LEVEL === 'สูง').forEach(c => {
    const group = c.GROUP;
    if (!c.PID || !c.CHRONIC_SUGGEST || existing.has(`${c.PID}|${group}`)) { skipped++; return; }
    const obj = buildChronicRowObject({ pid:c.PID, cid:c.CID, group, chronic:c.CHRONIC_SUGGEST, date:c.DATE_DIAG }, header);
    rows.push(objectToRowByHeader(header, obj));
    existing.add(`${c.PID}|${group}`);
    added++;
  });
  return { added, skipped };
}
async function exportFixed43Zip(){
  if (!hasData()) { alert('กรุณาอัปโหลด ZIP ก่อน'); return; }
  if (!state.files['chronicfu.txt']) { alert('ไม่พบ chronicfu.txt จึง export 43 ZIP แบบเติม CHRONICFU ไม่ได้'); return; }
  if (!state.files['chronic.txt']) { alert('ไม่พบ chronic.txt จึงเพิ่มทะเบียน CHRONIC ไม่ได้'); return; }
  state.audit.drafts = buildDraftRows();
  const outRows = {};
  Object.entries(state.files).forEach(([name, f]) => outRows[name] = cloneRows(f.rows));
  const fuResult = applyChronicFuFixes(outRows['chronicfu.txt']);
  const includeChronic = $('includeChronicAdds')?.checked;
  const chronicResult = includeChronic ? applyChronicAdds(outRows['chronic.txt']) : { added:0, skipped:0 };
  const zip = new JSZip();
  Object.entries(state.files).forEach(([name, f]) => {
    zip.file(name, serializeTxtFile(f, outRows[name]));
  });
  const blob = await zip.generateAsync({ type:'blob' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = `F43_CHRONICFU_FIXED_${state.hcode || 'HDC'}_${today8()}.zip`;
  a.click();
  URL.revokeObjectURL(a.href);
  alert(`Export 43 ZIP เสร็จแล้ว\nเพิ่ม CHRONICFU ${fuResult.added} แถว\nเติม CHRONICFU เดิม ${fuResult.updated} แถว\nข้ามเพราะไม่มี SEQ ${fuResult.skipped} แถว\nเพิ่ม CHRONIC ${chronicResult.added} แถว${includeChronic ? '' : ' (ปิดตัวเลือกเพิ่ม CHRONIC)'}`);
}
function copyBrief(){
  if (!hasData()) { alert('กรุณาอัปโหลด ZIP ก่อน'); return; }
  const text = [
    `CHRONICFU/SEQ Audit HCODE ${state.hcode || '-'}`,
    `CHRONIC active: ${state.audit.chronicPatients}`,
    `SERVICE visits: ${state.audit.serviceVisits}`,
    `Matched CHRONICFU: ${state.audit.matchedFu}`,
    `SERVICE มีแต่ CHRONICFU ไม่ตรง/ไม่ออก: ${state.audit.missing.length}`,
    `CHRONICFU SEQ ว่าง/ไม่ตรง SERVICE: ${state.audit.seqProblems.length}`,
    `CHRONICFU vital/normal field ว่าง: ${state.audit.incomplete.length}`,
    '',
    'ข้อเสนอ: ตรวจหน้าจอบันทึก NCD/FU และ export mapping ให้ CHRONICFU generate ด้วย HOSPCODE+PID+SEQ+DATE_SERV ที่ตรง SERVICE'
  ].join('\n');
  navigator.clipboard?.writeText(text).then(() => alert('Copy สรุปแล้ว')).catch(() => $('exportInfo').innerHTML = `<textarea style="width:100%;min-height:160px">${esc(text)}</textarea>`);
}
</script>
</body>
</html>

Youez - 2016 - github.com/yon3zu
LinuXploit