/* page_bank_recon.jsx — กระทบยอดธนาคาร (Bank Reconciliation)
 *
 * เป้าหมาย: กระทบ "รายการที่บันทึกในระบบ (PV)" กับ "รายการเดินบัญชีจริงจากธนาคาร (statement)"
 *   ราย "บัญชี" ราย "เดือน" เพื่อตอบ:
 *     • ทั้งเดือน ยกมา/เคลื่อนไหว/คงเหลือ เท่าไหร่ (จากตัว statement เอง)
 *     • PV เกิดจากบัญชีไหน · ครบตาม statement ไหม
 *     • รายการไหน "เกิดจริงแต่ยังไม่ลงระบบ" (ขาดบันทึก) → กดบันทึกเป็นจ่ายจริงได้
 *       แล้วเด้งเข้า Actual หน้า Cashflow (เขียน forecastEntries STATUS=ACTUAL)
 *
 * เก็บ statement + mapping + สถานะกระทบ ใน localStorage (v1, ไม่แตะ Sheets sync)
 *   — statement เป็น working data (re-import ได้) · ความจริงทางการเงินไหลเข้า forecastEntries (synced)
 *
 * Reuse helper จาก page_bank_diary.jsx (global): bdNum, bdDigits, bdISO, bdAcct,
 *   bdAcctMatchesCheck, bdLast4, bdBrand, bdNormPV  · parse ไฟล์ด้วย global XLSX (โหลดใน index.html)
 */
'use strict';

const { useState: brState, useMemo: brMemo, useEffect: brEffect, useRef: brRef } = React;

// ─── localStorage store (v1 — จุดเดียวที่จะสลับเป็น synced v2 ภายหลัง) ──────────
const BR_LS_STMT  = 'wtp-bankrecon-stmt-v1';   // { [accountNo]: { 'YYYY-MM': StatementLine[] } }
const BR_LS_MAP   = 'wtp-bankrecon-map-v1';    // { [brandKey]: ColumnMapping }
const BR_LS_STATE = 'wtp-bankrecon-state-v1';  // { [lineId]: { decision, forecastId } }
const BR_LS_BOOK  = 'wtp-bankrecon-book-v1';   // { [accountNo]: { 'YYYY-MM': { meta, moves[], outs[] } } } (Mango book)
const BR_LS_MATCH = 'wtp-bankrecon-match-v1';  // [ { id, accountNo, ym, bookIds[], stmIds[], status, ... } ] (Mango ↔ STM matches)
const BankReconStore = {
  _get(k, def) { try { const v = JSON.parse(localStorage.getItem(k) || 'null'); return v == null ? def : v; } catch (_) { return def; } },
  _set(k, v)   { try { localStorage.setItem(k, JSON.stringify(v)); } catch (_) {} },
  getLines()   { return this._get(BR_LS_STMT, {}); },
  setLines(v)  { this._set(BR_LS_STMT, v); },
  getMapping() { return this._get(BR_LS_MAP, {}); },
  setMapping(v){ this._set(BR_LS_MAP, v); },
  getState()   { return this._get(BR_LS_STATE, {}); },
  setState(v)  { this._set(BR_LS_STATE, v); },
  getBook()    { return this._get(BR_LS_BOOK, {}); },
  setBook(v)   { this._set(BR_LS_BOOK, v); },
  getMatches() { return this._get(BR_LS_MATCH, []); },
  setMatches(v){ this._set(BR_LS_MATCH, v); },
};

// ── แปลง nested {acct:{ym:[line]}} ↔ flat rows (sync เป็น entity table bankReconLines) ──
//    cloud = แหล่งข้อมูลจริง (ทีมเห็นร่วม) · localStorage = cache/ออฟไลน์
function brLinesToRows(linesAll) {
  const rows = [];
  Object.keys(linesAll || {}).forEach(acct => {
    const byYm = linesAll[acct] || {};
    Object.keys(byYm).forEach(ym => {
      (byYm[ym] || []).forEach(l => rows.push({
        id: l.id, accountNo: acct, ym: ym, date: l.date || '', amount: l.amount,
        desc: l.desc || '', ref: l.ref || '', balance: (l.balance == null ? '' : l.balance), idx: (l._idx == null ? 0 : l._idx),
      }));
    });
  });
  return rows;
}
function brRowsToLines(rows) {
  const out = {};
  (rows || []).forEach(r => {
    const acct = r.accountNo, ym = r.ym;
    if (!acct || !ym) return;
    (out[acct] = out[acct] || {});
    (out[acct][ym] = out[acct][ym] || []).push({
      id: r.id, date: r.date || '', amount: Number(r.amount) || 0, desc: r.desc || '',
      ref: r.ref || '', balance: (r.balance === '' || r.balance == null) ? null : Number(r.balance),
      bankAcct: acct, _idx: Number(r.idx) || 0,
    });
  });
  Object.keys(out).forEach(a => Object.keys(out[a]).forEach(y => out[a][y].sort((x, z) => (x._idx || 0) - (z._idx || 0))));
  return out;
}
function brStateToRows(stateMap) {
  return Object.keys(stateMap || {}).map(id => ({
    id: id, decision: (stateMap[id] && stateMap[id].decision) || '', forecastId: (stateMap[id] && stateMap[id].forecastId) || '',
    pvRef: (stateMap[id] && stateMap[id].pvRef) || '',   // decision='matched' → อ้างอิง PV ที่จับคู่มือ (id หรือเลขที่ PV)
  }));
}
function brRowsToState(rows) {
  const m = {};
  (rows || []).forEach(r => { if (r && r.id != null && r.id !== '') m[r.id] = { decision: r.decision || '', forecastId: r.forecastId || '', pvRef: r.pvRef || '' }; });
  return m;
}

// ── สมุดบัญชี Mango (งบกระทบยอด) ↔ flat rows (sync entity bankReconBook) ───────────
//    nested { acct: { ym: { meta:{opening,bankAsDate,bankAsDateBal}, moves:[move], outs:[out] } } }
//    kind ('meta'|'move'|'out') แยกชนิดแถวตอน flatten — move = รายการเดินบัญชีที่เอาไปจับคู่
function brBookToRows(bookAll) {
  const rows = [];
  Object.keys(bookAll || {}).forEach(acct => {
    const byYm = bookAll[acct] || {};
    Object.keys(byYm).forEach(ym => {
      const b = byYm[ym] || {};
      if (b.meta) rows.push({ id: acct + '|' + ym + '|__meta', accountNo: acct, ym: ym, kind: 'meta',
        opening: (b.meta.opening == null ? '' : b.meta.opening), bankAsDate: b.meta.bankAsDate || '',
        bankAsDateBal: (b.meta.bankAsDateBal == null ? '' : b.meta.bankAsDateBal) });
      (b.moves || []).forEach(m => rows.push({ id: m.id, accountNo: acct, ym: ym, kind: 'move',
        bkDate: m.bkDate || '', vno: m.vno || '', vdate: m.vdate || '', vendor: m.vendor || '',
        chqNo: m.chqNo || '', chqDate: m.chqDate || '', remark: m.remark || '',
        debit: m.debit || 0, credit: m.credit || 0, amount: m.amount || 0,
        balance: (m.balance == null ? '' : m.balance), idx: (m._idx == null ? 0 : m._idx) }));
      (b.outs || []).forEach((o, i) => rows.push({ id: o.id || (acct + '|' + ym + '|out#' + i), accountNo: acct, ym: ym, kind: 'out',
        bkDate: o.bkDate || '', vno: o.vno || '', vendor: o.vendor || '', chqNo: o.chqNo || '', remark: o.remark || '', amount: o.amount || 0 }));
    });
  });
  return rows;
}
function brRowsToBook(rows) {
  const out = {};
  (rows || []).forEach(r => {
    const acct = r.accountNo, ym = r.ym;
    if (!acct || !ym) return;
    const o = (out[acct] = out[acct] || {});
    const b = (o[ym] = o[ym] || { meta: null, moves: [], outs: [] });
    if (r.kind === 'meta') b.meta = { opening: (r.opening === '' || r.opening == null) ? null : Number(r.opening),
      bankAsDate: r.bankAsDate || '', bankAsDateBal: (r.bankAsDateBal === '' || r.bankAsDateBal == null) ? null : Number(r.bankAsDateBal) };
    else if (r.kind === 'out') b.outs.push({ id: r.id, bkDate: r.bkDate || '', vno: r.vno || '', vendor: r.vendor || '',
      chqNo: r.chqNo || '', remark: r.remark || '', amount: Number(r.amount) || 0 });
    else b.moves.push({ id: r.id, bkDate: r.bkDate || '', vno: r.vno || '', vdate: r.vdate || '', vendor: r.vendor || '',
      chqNo: r.chqNo || '', chqDate: r.chqDate || '', remark: r.remark || '',
      debit: Number(r.debit) || 0, credit: Number(r.credit) || 0, amount: Number(r.amount) || 0,
      balance: (r.balance === '' || r.balance == null) ? null : Number(r.balance), _idx: Number(r.idx) || 0 });
  });
  Object.keys(out).forEach(a => Object.keys(out[a]).forEach(y => out[a][y].moves.sort((x, z) => (x._idx || 0) - (z._idx || 0))));
  return out;
}

// ── การจับคู่ Mango↔STM (เฉพาะที่ "ยืนยัน/จับคู่เอง" — suggestion คำนวณสด ไม่เก็บ) ↔ flat rows ──
//    1 record = 1 การจับคู่ · M-to-N = bookIds[]/stmIds[] หลายตัวในแถวเดียว (groupId แท็กกลุ่ม)
function brMatchToRows(matches) {
  return (matches || []).filter(m => m && m.id).map(m => ({
    id: m.id, accountNo: m.accountNo || '', ym: m.ym || '',
    bookIds: m.bookIds || [], stmIds: m.stmIds || [],
    status: m.status || 'confirmed', groupId: m.groupId || '',
    confidence: m.confidence || '', reason: m.reason || '', confirmedAt: m.confirmedAt || '',
  }));
}
function brRowsToMatch(rows) {
  return (rows || []).filter(r => r && r.id != null && r.id !== '' && r.accountNo).map(r => ({
    id: r.id, accountNo: r.accountNo, ym: r.ym || '',
    bookIds: Array.isArray(r.bookIds) ? r.bookIds : (r.bookIds ? [r.bookIds] : []),
    stmIds: Array.isArray(r.stmIds) ? r.stmIds : (r.stmIds ? [r.stmIds] : []),
    status: r.status || 'confirmed', groupId: r.groupId || '',
    confidence: r.confidence || '', reason: r.reason || '', confirmedAt: r.confirmedAt || '',
  }));
}

// ── assign deterministic id ให้ movements (idempotent re-import — ไม่ใช่ newId สุ่ม) ──
//    id = accountNo|ym|bkDate|vno|amount.toFixed(2)#n (n = dedupe counter ใน batch เดียวกัน,
//    แนวเดียวกับ brNormalizeLines) → re-import ไฟล์เดิม = id เดิม → upsert ทับ ไม่เกิดแถวซ้ำ
function brAssignBookIds(moves, accountNo, ym) {
  const cnt = {};
  return (moves || []).map((m, i) => {
    const base = accountNo + '|' + ym + '|' + (m.bkDate || '') + '|' + String(m.vno || '') + '|' + (Number(m.amount) || 0).toFixed(2);
    cnt[base] = (cnt[base] || 0) + 1;
    return Object.assign({}, m, { id: base + '#' + cnt[base], _idx: i });
  });
}

// ── re-link matches ตอนนำเข้าทับ book — กัน confirmed match orphan (book id เปลี่ยนเพราะแก้ ERP) ──
//    คล้าย brRelinkState: จับ book เก่า→ใหม่ด้วยลายเซ็น bkDate|abs(amount) + tie-break เลขเช็ค/vno
//    leg ใดหา book ใหม่ไม่เจอ → mark status='orphaned' (ตัดออกจาก % กระทบ) ไม่ลบทิ้ง.
//    ★ caller ต้องส่ง "เฉพาะ matches ของบัญชี+เดือนเดียวกับ book ที่นำเข้า" เท่านั้น.
function brRelinkMatches(scopedMatches, oldMoves, newMoves) {
  const oldById = {}; (oldMoves || []).forEach(m => { oldById[m.id] = m; });
  const newIds = new Set((newMoves || []).map(m => m.id));
  const sig = m => (m.bkDate || '') + '|' + Math.abs(Number(m.amount) || 0).toFixed(2);
  const pool = {}; (newMoves || []).forEach(m => { (pool[sig(m)] = pool[sig(m)] || []).push(m); });
  const used = new Set();
  let relinked = 0, orphaned = 0;
  const out = (scopedMatches || []).map(mt => {
    let changed = false, lost = false;
    const nbids = (mt.bookIds || []).map(bid => {
      if (newIds.has(bid)) return bid;                          // id เดิมยังอยู่ (เนื้อหาเหมือนเป๊ะ) → ไม่ย้าย
      const om = oldById[bid];
      if (!om) { lost = true; return bid; }                     // ไม่รู้จัก id เก่า → อาจหาย
      const cands = (pool[sig(om)] || []).filter(c => !used.has(c.id));
      if (!cands.length) { lost = true; return bid; }
      let nm = null;
      const oRef = bdDigits(om.chqNo || om.vno || '');
      if (oRef) nm = cands.find(c => bdDigits(c.chqNo || c.vno || '') === oRef);
      if (!nm) nm = cands[0];
      used.add(nm.id); changed = true; relinked++;
      return nm.id;
    });
    if (lost) { orphaned++; return Object.assign({}, mt, { bookIds: nbids, status: 'orphaned' }); }
    return changed ? Object.assign({}, mt, { bookIds: nbids }) : mt;
  });
  return { matches: out, relinked: relinked, orphaned: orphaned };
}

// ─── Helpers ────────────────────────────────────────────────────────────────
// brand key (ไทย/อังกฤษ) — ใช้เป็น key ของ mapping (บัญชีแบรนด์เดียวกัน map ครั้งเดียว)
function brBrandKey(acct) {
  const s = (String((acct && acct.bankName) || '') + ' ' + String((acct && acct.accountName) || '')).toUpperCase();
  if (/SCB|ไทยพาณิชย์|SIAM COMMERCIAL/i.test(s)) return 'SCB';
  if (/KTB|กรุงไทย|KRUNG ?THAI/i.test(s))         return 'KTB';
  if (/BBL|กรุงเทพ|BANGKOK BANK/i.test(s))        return 'BBL';
  if (/KBANK|KBNK|กสิกร|KASIKORN/i.test(s))       return 'KBANK';
  if (/BAY|กรุงศรี|AYUDHYA/i.test(s))             return 'BAY';
  if (/TTB|ทหารไทย|ธนชาต/i.test(s))               return 'TTB';
  if (/GSB|ออมสิน/i.test(s))                      return 'GSB';
  const up = String((acct && acct.bankName) || '').trim().toUpperCase();
  return up || 'BANK';
}

// เดาว่าไฟล์นี้เป็นของบัญชีไหน จากชื่อไฟล์ (เลขบัญชี/ชื่อแบงค์) — ไว้นำเข้าหลายไฟล์พร้อมกัน
// คืน account ที่เดาได้ (ผู้ใช้เปลี่ยนเองได้ใน modal) · ไม่เจอ → fallback (บัญชีที่เลือกอยู่)
function brDetectAccountForFile(fileName, accounts, fallback) {
  const list = accounts || [];
  const name = String(fileName || '');
  // 1) เลขบัญชีในชื่อไฟล์ (เลขติดกัน ≥4 ตัว เช่น "Mango-4863" / "...1362684863...")
  const runs = name.match(/\d{4,}/g) || [];
  for (let i = 0; i < runs.length; i++) {
    const run = runs[i];
    const hit = list.find(a => {
      const ad = bdDigits(a.accountNo); if (ad.length < 4) return false;
      return ad.indexOf(run) >= 0 || run.indexOf(ad) >= 0 || ad.slice(-4) === run.slice(-4);
    });
    if (hit) return hit;
  }
  // 2) คำว่าแบงค์ในชื่อไฟล์ — ใช้ได้เฉพาะเมื่อมีบัญชีแบงค์นั้นแค่ตัวเดียว (ไม่งั้นเดาไม่ออกว่าบัญชีไหน)
  const BR = { SCB: /SCB|ไทยพาณิช|SIAM/i, KTB: /KTB|กรุงไทย|KRUNG ?THAI/i, BBL: /BBL|กรุงเทพ|BANGKOK/i,
    KBANK: /KBANK|KBNK|กสิกร|KASIKORN/i, BAY: /BAY|กรุงศรี|AYUDHYA/i, TTB: /TTB|ทหารไทย|ธนชาต/i, GSB: /GSB|ออมสิน/i };
  for (const key in BR) {
    if (BR[key].test(name)) {
      const same = list.filter(a => brBrandKey(a) === key);
      if (same.length === 1) return same[0];
    }
  }
  return fallback || list[0] || null;
}

// number parse (เผื่อ Date object จาก XLSX → 0, comma → ตัด)
function brNum(v) {
  if (v == null || v === '') return 0;
  if (v instanceof Date) return 0;
  const n = parseFloat(String(v).replace(/,/g, '').replace(/[฿\s]/g, ''));
  return isNaN(n) ? 0 : n;
}
// เดือนไทย (ย่อ/เต็ม) → เลขเดือน — รองรับ statement เช่น "01-มิ.ย.-2569" (KBANK) / "04 มิ.ย. 2569" (BBL)
const BR_THAI_MONTH = {
  'มค': '01', 'กพ': '02', 'มีค': '03', 'เมย': '04', 'พค': '05', 'มิย': '06',
  'กค': '07', 'สค': '08', 'กย': '09', 'ตค': '10', 'พย': '11', 'ธค': '12',
  'มกราคม': '01', 'กุมภาพันธ์': '02', 'มีนาคม': '03', 'เมษายน': '04', 'พฤษภาคม': '05', 'มิถุนายน': '06',
  'กรกฎาคม': '07', 'สิงหาคม': '08', 'กันยายน': '09', 'ตุลาคม': '10', 'พฤศจิกายน': '11', 'ธันวาคม': '12',
};
// แปลงค่าวันที่ใดๆ → ISO 'YYYY-MM-DD' (รองรับ Date จาก XLSX, DD/MM/YYYY, เดือนไทย, พ.ศ., ISO)
function brToISO(v) {
  if (v == null || v === '') return '';
  if (v instanceof Date && !isNaN(v)) return bdISO(v);
  const s = String(v).trim();
  if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
  // เดือนไทย: "01-มิ.ย.-2569" | "04 มิ.ย. 2569 05:01"
  const tm = s.match(/(\d{1,2})[-\s]+([ก-๙.]{2,})[-\s]+(\d{4})/);
  if (tm) {
    const mm = BR_THAI_MONTH[tm[2].replace(/\./g, '')];
    if (mm) { let y = Number(tm[3]); if (y > 2400) y -= 543; return `${y}-${mm}-${String(Number(tm[1])).padStart(2, '0')}`; }
  }
  const m = s.match(/(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{2,4})/);   // DD/MM/YYYY
  if (m) {
    let [, dd, mm, yy] = m;
    if (yy.length === 2) yy = '20' + yy;
    let y = Number(yy); if (y > 2400) y -= 543;                       // พ.ศ. → ค.ศ.
    return `${y}-${String(Number(mm)).padStart(2, '0')}-${String(Number(dd)).padStart(2, '0')}`;
  }
  const t = Date.parse(s);
  return isNaN(t) ? '' : bdISO(new Date(t));
}
function brMonthOf(iso) { return String(iso || '').slice(0, 7); }
function brDateDiff(a, b) {
  const ta = Date.parse(a + 'T00:00:00'), tb = Date.parse(b + 'T00:00:00');
  if (isNaN(ta) || isNaN(tb)) return 999;
  return Math.abs(Math.round((tb - ta) / 86400000));
}
const BR_MONTHS_TH = ['ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.', 'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.'];
function brFmtMonth(ym) {
  const [y, m] = String(ym || '').split('-').map(Number);
  if (!y || !m) return ym || '—';
  return `${BR_MONTHS_TH[m - 1]} ${y}`;
}

// decode text/CSV → string · ลอง UTF-8 ก่อน (SCB) ถ้าไม่ใช่ fallback windows-874/TIS-620 (KBANK/BBL ภาษาไทยเก่า)
function brDecodeText(buf) {
  try { return new TextDecoder('utf-8', { fatal: true }).decode(buf); }
  catch (_) {
    try { return new TextDecoder('windows-874').decode(buf); }
    catch (_2) { return new TextDecoder('utf-8').decode(buf); }
  }
}
// ─── ไฟล์ → AOA (CSV decode เอง · Excel ผ่าน global XLSX) ─────────────────────
function brParseFile(file, password) {
  return file.arrayBuffer().then(buf => {
    const name = (file.name || '').toLowerCase();
    let wb;
    if (/\.(csv|txt)$/.test(name)) {
      wb = XLSX.read(brDecodeText(buf), { type: 'string', raw: true });        // decode เอง กัน mojibake ไทย
    } else {
      // .xls/.xlsx binary — ถ้ามีรหัส (เช่น KTB) ส่ง password ให้ SheetJS ถอด (RC4/Standard)
      const opts = { type: 'array', cellDates: true };
      if (password) opts.password = password;
      wb = XLSX.read(buf, opts);
    }
    const sheets = {};
    wb.SheetNames.forEach(n => { sheets[n] = XLSX.utils.sheet_to_json(wb.Sheets[n], { header: 1, blankrows: false, defval: '' }); });
    return { sheetNames: wb.SheetNames, sheets };
  });
}
// เดาแถว header — เลือกแถวที่มี "หัวคอลัมน์" มากสุด (กัน false-positive กับแถวสรุปที่มี "วันที่" คำเดียว)
//   จำเป็นเพราะ blankrows:false ตัดแถวว่างทิ้ง → index header ขยับ (เช่น KBANK/BBL มีบล็อกสรุปด้านบน)
function brGuessHeaderRow(aoa) {
  const KEY = /(date|วันที่|debit|credit|เดบิต|เครดิต|amount|จำนวน|ยอด|balance|คงเหลือ|description|รายการ|รายละเอียด|คำอธิบาย|cheque|เช็ค|withdraw|deposit|ถอน|ฝาก|หักบัญชี)/i;
  let best = -1, bestScore = 1;
  for (let i = 0; i < Math.min(aoa.length, 30); i++) {
    const row = aoa[i] || [];
    if (row.filter(c => c !== '' && c != null).length < 3) continue;
    const score = row.filter(c => KEY.test(String(c))).length;       // นับคอลัมน์ที่ดูเป็นหัวตาราง
    if (score > bestScore) { bestScore = score; best = i; }
  }
  if (best >= 0) return best;
  for (let i = 0; i < Math.min(aoa.length, 30); i++) if ((aoa[i] || []).filter(c => c !== '' && c != null).length >= 3) return i;
  return 0;
}
// auto-map คอลัมน์จากชื่อ header (เดาให้ก่อน ผู้ใช้แก้ได้)
function brAutoMapping(headers) {
  const find = (re) => { for (let i = 0; i < headers.length; i++) if (re.test(String(headers[i]))) return i; return -1; };
  const dateCol  = find(/date|วันที่|วันที|posting/i);
  const debitCol = find(/debit|เดบิต|withdraw|ถอน|จ่าย|ออก/i);
  const creditCol= find(/credit|เครดิต|deposit|ฝาก|รับ|เข้า/i);
  const amountCol= find(/amount|จำนวนเงิน|^ยอด|มูลค่า/i);
  const balCol   = find(/balance|คงเหลือ|ยอดคงเหลือ/i);
  const descCol  = find(/description|รายการ|รายละเอียด|detail|narrative|memo|channel/i);
  const refCol   = find(/cheque|เช็ค|ref|เลขที่|อ้างอิง|transaction|no\.?$/i);
  // ★ คอลัมน์รวม "ถอนเงิน/ฝากเงิน" (เช่น KTB) — ชื่อเดียวจับได้ทั้ง debit&credit → คอลัมน์เดียวเป็นยอด signed
  //   (บวก=เข้า ลบ=ออก) ไม่ใช่ split → ถ้าปล่อย split จะคิด debit−credit = 0 ทุกแถว = อ่านไม่ได้
  const sameCol = debitCol >= 0 && debitCol === creditCol;
  const mode = (debitCol >= 0 && creditCol >= 0 && !sameCol) ? 'split' : 'single';
  // sameCol (คอลัมน์รวม ถอน/ฝาก) → ใช้คอลัมน์นั้นเป็นยอดเสมอ (กัน amountCol ไป match "ยอดคงเหลือ" ผิด)
  const singleAmt = sameCol ? debitCol : (amountCol >= 0 ? amountCol : (debitCol >= 0 ? debitCol : 1));
  return {
    headerRow: 0,  // ตั้งจริงตอนใช้
    mode,
    dateCol:   dateCol >= 0 ? dateCol : 0,
    amountCol: singleAmt,
    // flip เฉพาะ "ยอดถอนล้วน" (เจอ debit แต่ไม่เจอ credit → บวก=จ่ายออก); คอลัมน์รวม ถอน/ฝาก = signed ไม่ flip
    outflowPositive: mode === 'single' && debitCol >= 0 && !sameCol && creditCol < 0,
    debitCol:  debitCol >= 0 ? debitCol : null,
    creditCol: (creditCol >= 0 && !sameCol) ? creditCol : null,
    descCol:   descCol >= 0 ? descCol : null,
    refCol:    refCol >= 0 ? refCol : null,
    balanceCol:balCol >= 0 ? balCol : null,
  };
}

// ── Preset ราย "แบรนด์" จากไฟล์ตัวอย่างจริง — map คอลัมน์อัตโนมัติครั้งแรก (ปรับเองได้ใน modal) ──
//   index อิงรูปแบบไฟล์จริง (header อาจไม่อยู่บรรทัดแรก) · KTB เป็น .xls "มีรหัสผ่าน" →
//   เปิดด้วย Excel แล้ว Save As .xlsx/.csv (ไม่ใส่รหัส) ก่อนนำเข้า
const BR_PRESETS = {
  // SCB (UTF-8) · header แถวแรก · acctCol(0)=ไฟล์รวมหลายบัญชี · Note(15)=เลขที่ PV → กระทบยอดแม่นด้วย ref
  SCB:   { headerRow: 0, mode: 'split', acctCol: 0, dateCol: 5, debitCol: 11, creditCol: 12, balanceCol: 13, descCol: 14, refCol: 15 },
  // KBANK (TIS-620) · header แถวที่ 10 (index 9) · มีบล็อกสรุปด้านบน
  KBANK: { headerRow: 9, mode: 'split', dateCol: 0, debitCol: 4, creditCol: 5, balanceCol: 6, descCol: 2, refCol: 7 },
  // BBL (TIS-620) · header แถวที่ 4 (index 3) · สรุปด้านบน + footer ด้านล่าง
  BBL:   { headerRow: 3, mode: 'split', dateCol: 0, debitCol: 3, creditCol: 4, balanceCol: 5, descCol: 2, refCol: null },
  // KTB (Save As .xlsx/.csv จากไฟล์ Historical export ที่เข้ารหัส) · header แถวที่ 10 (index 9) ·
  //   คอลัมน์ "ถอนเงิน/ฝากเงิน" (index 4) = ยอด signed คอลัมน์เดียว (บวก=เข้า ลบ=ออก) → single, ไม่ flip
  //   วันที่/เวลา(0) · รายการ(1) · รายละเอียด(2=desc) · หมายเลขเช็ค(3=ref) · ยอด(4) · ภาษี(5) · คงเหลือ(6) · ช่องทาง(7)
  KTB:   { headerRow: 9, mode: 'single', dateCol: 0, amountCol: 4, outflowPositive: false, balanceCol: 6, descCol: 2, refCol: 3 },
};
// AOA + mapping → StatementLine[] (amount: signed, − = จ่ายออก)
function brNormalizeLines(aoa, mapping, accountNo) {
  const rows = aoa.slice((mapping.headerRow || 0) + 1);
  const out = [];
  const baseCount = {};
  const filterAcct = mapping.acctCol != null && bdDigits(accountNo).length >= 4;  // ไฟล์รวมหลายบัญชี (เช่น SCB) → เอาเฉพาะบัญชีที่เลือก
  rows.forEach(r => {
    if (filterAcct && !bdAcctMatchesCheck(accountNo, r[mapping.acctCol])) return;
    const date = brToISO(r[mapping.dateCol]);
    if (!date) return;                          // ไม่มีวันที่ที่อ่านได้ = แถว header/total/footer → ข้าม
    let amount;
    if (mapping.mode === 'split') {
      const dr = brNum(r[mapping.debitCol]);    // เดบิต = จ่ายออก
      const cr = brNum(r[mapping.creditCol]);   // เครดิต = รับเข้า
      amount = cr - dr;
    } else {
      let a = brNum(r[mapping.amountCol]);
      if (mapping.outflowPositive) a = -a;      // บางแบงก์โชว์ยอดถอน/จ่ายเป็นเลขบวก
      amount = a;
    }
    if (!amount) return;                        // ข้ามแถวยอด 0
    const desc    = mapping.descCol  != null ? String(r[mapping.descCol]  || '').trim() : '';
    const ref     = mapping.refCol   != null ? String(r[mapping.refCol]   || '').trim() : '';
    const balance = mapping.balanceCol != null ? (r[mapping.balanceCol] === '' ? null : brNum(r[mapping.balanceCol])) : null;
    const base = `${accountNo}|${date}|${amount.toFixed(2)}|${(ref || desc).slice(0, 18)}`;
    baseCount[base] = (baseCount[base] || 0) + 1;
    out.push({ id: base + '#' + baseCount[base], date, amount, desc, ref, balance, bankAcct: accountNo, _idx: out.length, raw: r });
  });
  return out;
}

// ── Re-link reconState ตอนนำเข้าทับ — กัน "งานเก่าหาย" (แมต/โอน/บันทึกจ่ายจริง) ──────────────────
//   line.id = บัญชี|วันที่|ยอด|รายละเอียด#n → ถ้านำเข้าใหม่แล้ว map/ข้อความต่างนิดเดียว id เปลี่ยน
//   → reconState (key by line.id) หาไม่เจอ = รายการเด้งกลับเป็น "ยังไม่ทำ" (+เสี่ยงบันทึกซ้ำ).
//   วิธีแก้: ก่อนทับ ย้าย decision ของ "รายการเดิม" (id เก่า) → "รายการใหม่ที่เป็นตัวเดียวกัน"
//   จับด้วยลายเซ็นที่ทน = วันที่|ยอด (ไม่ขึ้นกับ desc/ref/วิธี map) + tie-break ด้วยเลขเช็ค/ref ถ้ามี.
//   คืน { ns, moved } — moved = จำนวนรายการที่ย้ายสถานะมาได้.
function brRelinkState(reconState, oldLines, newLines) {
  const ns = Object.assign({}, reconState || {});
  let moved = 0;
  const newIds = new Set((newLines || []).map(l => l.id));
  const sig = l => l.date + '|' + Math.abs(Number(l.amount) || 0).toFixed(2);
  const pool = {};                                   // ลายเซ็น → คิวรายการใหม่ (ตามลำดับในไฟล์)
  (newLines || []).forEach(l => { (pool[sig(l)] = pool[sig(l)] || []).push(l); });
  const used = new Set();
  // เรียงรายการเก่าที่มี state ตามลำดับเดิม (deterministic) แล้วจับคู่
  (oldLines || []).slice().sort((a, b) => (a._idx || 0) - (b._idx || 0)).forEach(ol => {
    const st = ns[ol.id];
    if (!st || !st.decision) return;                 // ไม่มีสถานะที่เคยทำ → ข้าม
    if (newIds.has(ol.id)) return;                   // id เดิมยังอยู่ในไฟล์ใหม่ (เนื้อหาเหมือนเป๊ะ) → ไม่ต้องย้าย
    const cands = (pool[sig(ol)] || []).filter(c => !used.has(c.id));
    if (!cands.length) return;                       // ไม่มีรายการใหม่ที่ตรง = รายการหายจากไฟล์จริง → คงสถานะเก่าไว้
    let nl = null;
    const olRef = bdDigits(ol.ref || '');
    if (olRef) nl = cands.find(c => bdDigits(c.ref || '') === olRef);   // เลขเช็ค/ref ตรง → แม่นกว่า
    if (!nl) nl = cands[0];
    used.add(nl.id);
    if (nl.id !== ol.id) { ns[nl.id] = st; delete ns[ol.id]; moved++; }
  });
  return { ns, moved };
}

// ─── มุมมองรายเดือน (ยกมา → เคลื่อนไหว → คงเหลือ) จากตัว statement เอง ──────────
function brMonthlyView(lines) {
  // sort วันที่ + ลำดับในไฟล์ — รองรับไฟล์เรียงใหม่→เก่า (newest-first เช่น BBL): รายการวันเดียวกัน
  //   เรียงกลับลำดับไฟล์ให้เป็นเวลาจริง เพื่อให้ running balance/ยอดยกมา-คงเหลือ ตรงกับ statement
  const fileDesc = lines.length > 1 && lines[0].date > lines[lines.length - 1].date;
  const sorted = lines.slice().sort((a, b) =>
    a.date < b.date ? -1 : a.date > b.date ? 1 : (fileDesc ? (b._idx || 0) - (a._idx || 0) : (a._idx || 0) - (b._idx || 0)));
  const inTotal  = sorted.filter(l => l.amount > 0).reduce((s, l) => s + l.amount, 0);
  const outTotal = sorted.filter(l => l.amount < 0).reduce((s, l) => s - l.amount, 0);
  const hasBal = sorted.some(l => l.balance != null);
  let opening = null, closing = null;
  if (hasBal && sorted.length) {
    const first = sorted.find(l => l.balance != null);
    const last  = sorted.slice().reverse().find(l => l.balance != null);
    if (first) opening = first.balance - first.amount;   // ยอดก่อนรายการแรก = ยกมา
    if (last)  closing = last.balance;
  }
  let run = opening != null ? opening : 0;
  const rows = sorted.map(l => { run += l.amount; return { ...l, running: l.balance != null ? l.balance : run }; });
  if (opening == null && sorted.length) closing = run;
  const expectedClosing = opening != null ? opening + (inTotal - outTotal) : null;
  const balOK = (closing != null && expectedClosing != null) ? Math.abs(closing - expectedClosing) < 0.01 : null;
  return { sorted: rows, inTotal, outTotal, opening, closing, expectedClosing, balOK, hasBal, count: sorted.length };
}

// ─── Reconcile engine — statement (จ่ายออก) ↔ PV ──────────────────────────────
//   3 ถัง: matched · missing (เกิดจริงแต่ยังไม่ลงระบบ) · unmatchedPv (ลงระบบแต่ยังไม่ออกจริง)
function brReconcile(opts) {
  const lines = opts.lines || [], pvs = opts.pvs || [], reconState = opts.reconState || {};
  // refPool = PV ทั้งระบบที่มีเลขที่ PV — ใช้เฉพาะ pass 0 (จับด้วยเลข PV ที่ statement อ้างถึงตรงๆ)
  // เพื่อให้รายการที่ statement พิมพ์เลข PV ไว้แล้ว ไม่หลุดเป็น "ขาดบันทึก" เพียงเพราะ PV ลงคนละเดือน/คนละช่องบัญชี
  const refPool = opts.refPool || pvs;
  const tol = opts.amtTol != null ? opts.amtTol : 0.01;
  const win = opts.dateWindow != null ? opts.dateWindow : 3;
  const outLines = lines.filter(l => l.amount < 0);
  const inLines  = lines.filter(l => l.amount > 0);
  const pvAvail  = pvs.map((p, i) => ({ p, i, used: false }));
  // PV ใน refPool ที่ไม่อยู่ใน scope (คนละเดือน/บัญชี) → ใช้ได้เฉพาะ pass 0 และไม่โผล่ในถัง unmatchedPv
  const pvKey = p => (p.id != null ? 'i' + p.id : 'k' + p.pvNo + '@' + p.date + '@' + p.amount);
  const scopedKeys = new Set(pvs.map(pvKey));
  const refExtra = refPool.filter(p => p.pvNo && !scopedKeys.has(pvKey(p))).map((p, i) => ({ p, i: 'x' + i, used: false, extra: true }));
  const matched = [], recorded = [], transfers = [];
  const toMatch = [], manualMatched = [];
  outLines.forEach(l => {
    const st = reconState[l.id];
    if (st && st.decision === 'recorded') recorded.push({ line: l, forecastId: st.forecastId });
    else if (st && st.decision === 'transfer') transfers.push(l);   // โอนระหว่างบัญชี — ไม่นับเป็นรายจ่าย
    else if (st && st.decision === 'matched') manualMatched.push({ line: l, pvRef: st.pvRef });  // ผูก PV เอง
    else toMatch.push(l);
  });
  toMatch.sort((a, b) => (a.date + a.id) < (b.date + b.id) ? -1 : 1);

  // ── จับคู่เอง (manual match) — ผู้ใช้ผูก statement line ↔ PV เอง (เคสเลขเอกสารเปลี่ยน auto-match พลาด) ──
  //   จองก่อน pass อัตโนมัติ เพื่อกัน PV ที่ผูกมือไปโดนจับให้บรรทัดอื่น · ไม่สร้าง Actual ใหม่
  //   (PV เป็น Actual ในระบบอยู่แล้ว) — แค่ทำให้ทั้งสองฝั่งออกจากถังค้าง
  manualMatched.forEach(mm => {
    const ref = brNormRef(mm.pvRef);
    const c = pvAvail.find(x => !x.used && (String(x.p.id) === String(mm.pvRef) || (ref && brNormRef(x.p.pvNo) === ref)));
    if (c) c.used = true;
    matched.push({ line: mm.line, pv: c ? c.p : { pvNo: mm.pvRef, amount: -Math.abs(mm.line.amount), date: mm.line.date },
      via: 'manual', manual: true, score: 300 });
  });

  // ── pass 0: ref = เลขที่ PV ใน statement (เช่น SCB คอลัมน์ Note = PVxxxx) — แม่นสุด ──
  //   รองรับ 1 PV หลายขา (เลข PV เดียวกันหลายบรรทัด → จับเป็นกลุ่มเดียว)
  const pvByNo = {};
  pvAvail.concat(refExtra).forEach(c => { const k = brNormRef(c.p.pvNo); if (k) (pvByNo[k] = pvByNo[k] || []).push(c); });
  const linesByPv = {}, rest = [];
  toMatch.forEach(l => {
    const k = brFindPvInRef(l.ref, pvByNo);
    if (k) (linesByPv[k] = linesByPv[k] || []).push(l);
    else rest.push(l);
  });
  Object.keys(linesByPv).forEach(k => {
    const ls = linesByPv[k], cs = pvByNo[k].filter(c => !c.used);
    if (!cs.length) { rest.push(...ls); return; }
    cs.forEach(c => c.used = true);
    const lineSum = ls.reduce((s, x) => s + Math.abs(x.amount), 0);
    const pvSum   = cs.reduce((s, c) => s + Math.abs(c.p.amount), 0);
    matched.push({
      line: ls.length === 1 ? ls[0] : { _group: true, lines: ls, date: ls[0].date, amount: -lineSum, desc: `รวม ${ls.length} รายการ`, ref: k },
      lines: ls, pvs: cs.map(c => c.p), via: 'ref', score: 200,
      amtMismatch: Math.abs(lineSum - pvSum) > tol ? (lineSum - pvSum) : 0,
    });
  });

  // ── pass 1: 1:1 amount+date ──
  rest.sort((a, b) => (a.date + a.id) < (b.date + b.id) ? -1 : 1);
  const after1 = [];
  rest.forEach(l => {
    const target = Math.abs(l.amount);
    let best = null, bestScore = -1;
    pvAvail.forEach(c => {
      if (c.used) return;
      if (Math.abs(c.p.amount - target) > tol) return;
      const dd = c.p.date ? brDateDiff(l.date, c.p.date) : 999;
      if (dd > win) return;
      let score = 50 - dd * 10;
      if (l.ref && c.p.chqNo && bdDigits(l.ref) && bdDigits(l.ref) === bdDigits(c.p.chqNo)) score += 100;
      if (Math.abs(c.p.amount - target) < 0.005) score += 10;
      if (score > bestScore) { bestScore = score; best = c; }
    });
    if (best) { best.used = true; matched.push({ line: l, pv: best.p, score: bestScore, via: '1:1' }); }
    else after1.push(l);
  });

  // ── pass 2: split (1 statement line = ผลรวมของ 2-3 PV ในกรอบวัน) ──
  const missing = [];
  after1.forEach(l => {
    const target = Math.abs(l.amount);
    const cand = pvAvail.filter(c => !c.used && c.p.date && brDateDiff(l.date, c.p.date) <= win).sort((a, b) => b.p.amount - a.p.amount);
    const combo = brFindSubset(cand, target, tol, 3);
    if (combo) { combo.forEach(c => c.used = true); matched.push({ line: l, pvs: combo.map(c => c.p), score: 5, via: 'split' }); }
    else missing.push(l);
  });

  const unmatchedPv = pvAvail.filter(c => !c.used).map(c => c.p);
  const stats = {
    matched: matched.length, missing: missing.length, unmatchedPv: unmatchedPv.length, recorded: recorded.length, transfers: transfers.length,
    missingAmt: missing.reduce((s, l) => s + Math.abs(l.amount), 0),
    unmatchedPvAmt: unmatchedPv.reduce((s, p) => s + Math.abs(p.amount), 0),
  };
  return { matched, missing, unmatchedPv, recorded, transfers, inLines, stats };
}
function brNormRef(s) { return String(s || '').replace(/\s+/g, '').toUpperCase(); }
// หา pvNo ใน ref ของ statement (ref อาจมีข้อความปน) — คืน key ที่ match ใน pvByNo
function brFindPvInRef(ref, pvByNo) {
  const r = brNormRef(ref);
  if (!r) return null;
  if (pvByNo[r]) return r;
  const keys = Object.keys(pvByNo);
  for (let i = 0; i < keys.length; i++) { if (keys[i].length >= 6 && r.indexOf(keys[i]) >= 0) return keys[i]; }
  return null;
}
// หา subset (≤maxN) ของ cand ที่ผลรวม ≈ target (greedy + backtrack เล็กน้อย)
function brFindSubset(cand, target, tol, maxN) {
  const n = Math.min(cand.length, 12);   // bound
  const arr = cand.slice(0, n);
  let found = null;
  const dfs = (start, remain, picked) => {
    if (found) return;
    if (Math.abs(remain) <= tol && picked.length >= 2) { found = picked.slice(); return; }
    if (picked.length >= maxN) return;
    for (let i = start; i < arr.length; i++) {
      if (arr[i].p.amount - tol > remain) continue;   // เกิน target แล้ว ข้าม (เรียงมาก→น้อย)
      picked.push(arr[i]);
      dfs(i + 1, remain - arr[i].p.amount, picked);
      picked.pop();
      if (found) return;
    }
  };
  dfs(0, target, []);
  return found;
}

// ─── ใบรับเงิน (receipt) accessors — robust กับ seed/synced (UPPER) ───────────
function brRcDate(r)  { return brToISO(r.receiptDate || r.RECEIPT_DATE || r.date || ''); }
function brRcNet(r)   { return Number(r.netReceived) || Number(r.grossAmount) || Number(r.amount) || 0; }
function brRcGross(r) { return Number(r.grossAmount) || Number(r.netReceived) || Number(r.amount) || 0; }
function brRcBank(r)  { return String(r.bankAccount || r.BANK_ACCOUNT || r.Bank_AC || ''); }

// ─── Reconcile ฝั่งรับ — statement (เงินเข้าจริง = credit) ↔ ใบรับเงินในระบบ ─────
//   ตอบโจทย์ "เงินที่เข้ามามีรายการเกิดขึ้นจริงตามยอดที่ดึงเข้ามามั้ย" — เทียบ
//   เงินเข้าจริง (statement) กับ "ยอดที่ระบบบันทึกรับ" (receipts) รายตัว. 3 ถัง:
//     matched          = เงินเข้าจริง ↔ ใบรับ (ยอด net หรือ gross ±tol + วัน ±window)
//     receiptNoDeposit = ระบบบันทึกรับแล้ว แต่ไม่มีเงินเข้าจริง (= ยอดที่ดึงมา "ไม่จริง") ★ สำคัญสุด
//     depositNoReceipt = เงินเข้าจริง แต่ระบบยังไม่มีใบรับ (เงินเข้าอื่น/IV ที่ยังไม่บันทึก)
//   ★ ลองทั้ง netReceived (เงินเข้าหลังหักโอนสิทธิ — ปกติเข้าบัญชีจริง) และ grossAmount
//     (เผื่อเงินเข้าเต็มแล้วโอนออกแยก) — ตัวใดตรง statement ก็ถือว่าเจอ
function brReconcileAr(opts) {
  const inLines = (opts.inLines || []).slice();
  const receipts = (opts.receipts || []).slice();
  const tol = opts.amtTol != null ? opts.amtTol : 0.01;
  const win = opts.dateWindow != null ? opts.dateWindow : 7;   // เงินเข้าอาจ clear ช้ากว่าวันออกใบรับ
  const avail = inLines.map((l, i) => ({ l, i, used: false }));
  const matched = [], receiptNoDeposit = [];
  // เรียงใบรับตามวัน (deterministic) แล้วจับ 1:1 best score
  receipts.sort((a, b) => (brRcDate(a) < brRcDate(b) ? -1 : 1));
  receipts.forEach(rc => {
    const net = brRcNet(rc), gross = brRcGross(rc), rd = brRcDate(rc);
    let best = null, bestScore = -1, bestVia = 'net';
    avail.forEach(c => {
      if (c.used) return;
      const amt = c.l.amount;                                  // > 0 (เงินเข้า)
      const okNet   = Math.abs(amt - net) <= tol;
      const okGross = gross !== net && Math.abs(amt - gross) <= tol;
      if (!okNet && !okGross) return;
      const dd = rd ? brDateDiff(rd, c.l.date) : 999;
      if (dd > win) return;
      let score = 50 - dd;
      if (okNet) score += 5;                                   // net = เงินเข้าจริงน่าจะตรงกว่า
      if (score > bestScore) { bestScore = score; best = c; bestVia = okNet ? 'net' : 'gross'; }
    });
    if (best) { best.used = true; matched.push({ line: best.l, receipt: rc, via: bestVia, score: bestScore }); }
    else receiptNoDeposit.push(rc);
  });
  const depositNoReceipt = avail.filter(c => !c.used).map(c => c.l);
  const stats = {
    matched: matched.length,
    receiptNoDeposit: receiptNoDeposit.length,
    depositNoReceipt: depositNoReceipt.length,
    receiptNoDepositAmt: receiptNoDeposit.reduce((s, r) => s + brRcNet(r), 0),
    depositNoReceiptAmt: depositNoReceipt.reduce((s, l) => s + Math.abs(l.amount), 0),
  };
  return { matched, receiptNoDeposit, depositNoReceipt, stats };
}

// ─── Parser งบกระทบยอด Mango ERP — แยก 2 ส่วน (เคลื่อนไหว + Outstanding Cheque) ──
//   ไฟล์: "MM.YYYY งบกระทบยอด Mango-XXXX.xlsx" (XXXX=เลขบัญชี). โครงสร้าง:
//     R1 header · R2 Balance Forward · R3..=รายการ · "Bank as Date"=ยอด book ปลายเดือน
//     · "Outstanding Cheque"=หัวส่วน 2 · ส่วน 2=เช็ค/รายการค้างยังไม่ขึ้นธนาคาร
//   Debit=เงินเข้า(+) Credit=เงินออก(ค่าลบ) → amount = Debit+Credit (sign ตรงกับ STM: +เข้า −ออก)
function brParseMango(aoa, fileName) {
  const out = { account: '', ok: false, error: '', opening: null, bankAsDate: '', bankAsDateBal: null, movements: [], outstanding: [] };
  const nm = String(fileName || '');
  const am = nm.replace(/\.[^.]*$/, '').match(/(\d{4})(?!.*\d)/);   // เลขบัญชี 4 หลักท้าย (ก่อน .xlsx)
  out.account = am ? am[1] : '';
  if (!Array.isArray(aoa) || !aoa.length) { out.error = 'ไฟล์ว่าง'; return out; }
  // หาแถว header (มี "voucher" + "debit"/"credit")
  let hr = -1;
  for (let i = 0; i < Math.min(aoa.length, 15); i++) {
    const j = (aoa[i] || []).map(c => String(c || '').toLowerCase()).join('|');
    if (j.includes('voucher') && (j.includes('debit') || j.includes('credit'))) { hr = i; break; }
  }
  if (hr < 0) { out.error = 'ไม่พบหัวตาราง (Voucher No./Debit/Credit) — ตรวจว่าเป็นไฟล์งบกระทบยอด Mango'; return out; }
  const H = (aoa[hr] || []).map(c => String(c || '').trim().toLowerCase());
  const col = (...keys) => { for (let i = 0; i < H.length; i++) if (keys.some(k => H[i].includes(k))) return i; return -1; };
  const cBk = col('bk. date', 'bk.date', 'bk date'), cVno = col('voucher no'), cVdt = col('voucher date'),
        cVen = col('vendor'), cChq = col('cheque no'), cChqD = col('cheque date'), cRem = col('remark'),
        cDr = col('debit'), cCr = col('credit'), cBal = col('balance');
  const gv = (row, i) => i >= 0 ? row[i] : '';
  let phase = 'move';   // move → (เจอ Bank as Date) → (เจอ Outstanding Cheque) → out
  for (let i = hr + 1; i < aoa.length; i++) {
    const row = aoa[i] || [];
    const ven = String(gv(row, cVen) || '').trim();
    const venL = ven.toLowerCase();
    if (venL.includes('balance forward')) { out.opening = brNum(gv(row, cBal)); continue; }
    if (venL.includes('bank as date')) {
      out.bankAsDate = brToISO((ven.match(/\d{1,2}[\/\-.]\d{1,2}[\/\-.]\d{2,4}/) || [''])[0]);
      out.bankAsDateBal = brNum(gv(row, cBal)); continue;
    }
    if (venL.includes('outstanding')) { phase = 'out'; continue; }
    const vno = String(gv(row, cVno) || '').trim();
    if (!vno) continue;                                          // แถวว่าง/ผู้รวม
    const debit = brNum(gv(row, cDr)), credit = brNum(gv(row, cCr));
    const rec = {
      bkDate: brToISO(gv(row, cBk)), vno, vdate: brToISO(gv(row, cVdt)), vendor: ven,
      chqNo: String(gv(row, cChq) || '').trim(), chqDate: brToISO(gv(row, cChqD)),
      remark: String(gv(row, cRem) || '').trim(), debit, credit, amount: debit + credit, balance: brNum(gv(row, cBal)),
    };
    (phase === 'out' ? out.outstanding : out.movements).push(rec);
  }
  out.ok = true;
  return out;
}

// ── normalize ข้อความสำหรับเทียบ ref/ชื่อ (ตัดช่องว่าง/อักขระคั่น, พิมพ์ใหญ่) ──
function brTxtNorm(s) { return String(s || '').replace(/[\s\-_.,()#/\\|]+/g, '').toUpperCase(); }
// STM (ref/desc) มีเลขอ้างอิงของ Mango (vno = เลขที่ voucher/PV) ไหม — normalize แล้วเทียบ substring (ยาว ≥4)
function brRefInStm(mangoRef, stmRef, stmDesc) {
  const r = brTxtNorm(mangoRef);
  if (r.length < 4) return false;
  const hay = brTxtNorm(stmRef) + '|' + brTxtNorm(stmDesc);
  return hay.indexOf(r) >= 0;
}
// ชื่อผู้รับ (vendor) โผล่ใน STM desc/ref ไหม — จับ token ไทย/อังกฤษที่ยาวพอ (≥3) เพื่อแยกโอนยอดเท่ากันคนละคน
function brVendorInStm(vendor, stmDesc, stmRef) {
  const hay = brTxtNorm(stmDesc) + '|' + brTxtNorm(stmRef);
  if (hay.length < 3) return false;
  const toks = String(vendor || '').split(/[\s\-_.,()/\\|]+/).map(brTxtNorm).filter(t => t.length >= 3);
  return toks.some(t => hay.indexOf(t) >= 0);
}

// ─── Reconcile Mango (book) ↔ STM (bank) — หารายการลงบัญชี ขาด/เกิน เทียบ STM จริง ──
//   matched  = Mango ↔ STM (amount รวม sign ±tol + วัน ±win + สัญญาณ เลขเช็ค/PV/ชื่อผู้รับ)
//   stmOnly  = มีใน STM ไม่มีใน Mango = ธนาคารหักแล้ว "บัญชียังไม่ลง" (ลงไม่ครบ/ขาด)
//   bookOnly = มีใน Mango ไม่มีใน STM = บัญชีลงแล้ว "ธนาคารยังไม่มี" (ลงเกิน / เช็คยังไม่ขึ้น)
//   เฉพาะรายการเงินจริง (|amount| > tol) — ตัด JV ตัดมัดจำ/รับสินค้า (Debit=Credit=0) ที่ไม่กระทบเงินสด
//
//   ★ จับแบบ global-greedy (คะแนนสูงสุดทั้งกระดานก่อน) ไม่ใช่ per-move: คู่ที่มีสัญญาณแรง
//     (เลขเช็ค/เลขที่ PV/ชื่อผู้รับตรง) ล็อกก่อน เหลือเฉพาะที่กำกวมจริงให้ fallback ยอด+วัน
//     → แก้ปัญหา "โอนยอดเท่ากันหลายคน จับคู่สลับ" เพราะเดิมคิดแค่ระยะวัน
//   คู่ที่จับได้เพราะ "ยอด+วัน" ล้วน และมี STM ยอดเท่ากันในกรอบวัน >1 ตัว → flag ambiguous
//     (UI เตือน + ไม่เหมาโปะยืนยันอัตโนมัติ ให้คนตรวจผู้รับก่อน)
function brReconcileMango(opts) {
  const tol = opts.amtTol != null ? opts.amtTol : 0.01;
  const win = opts.dateWindow != null ? opts.dateWindow : 5;
  const moves = (opts.movements || []).filter(m => Math.abs(m.amount) > tol);
  const stm = (opts.stmLines || []);
  // 1) สร้างคู่ผู้สมัครทั้งหมด (amount ตรง + วันในกรอบ) + ให้คะแนนตามสัญญาณ
  const pairs = [];
  moves.forEach((m, mi) => {
    stm.forEach((l, li) => {
      if (Math.abs(l.amount - m.amount) > tol) return;           // amount ต้องตรง (รวม sign +เข้า/−ออก)
      const dd = (m.bkDate && l.date) ? brDateDiff(m.bkDate, l.date) : 999;
      if (dd > win) return;
      const chqOK = !!(m.chqNo && l.ref && bdDigits(m.chqNo) && bdDigits(m.chqNo) === bdDigits(l.ref));
      const pvOK  = brRefInStm(m.vno, l.ref, l.desc);             // เลขที่ voucher/PV โผล่ใน STM
      const venOK = brVendorInStm(m.vendor, l.desc, l.ref);      // ชื่อผู้รับโผล่ใน STM
      let score = 50 - dd;
      let via = 'amt';                                            // สัญญาณที่ทำให้ "มั่นใจ" (เรียง chq>pv>vendor)
      if (chqOK) { score += 100; via = 'chq'; }
      if (pvOK)  { score += 100; if (via === 'amt') via = 'pv'; }
      if (venOK) { score += 40;  if (via === 'amt') via = 'vendor'; }
      pairs.push({ mi, li, score, dd, via, strong: chqOK || pvOK || venOK });
    });
  });
  // 2) จับแบบ greedy คะแนนสูงสุดก่อน (tie → วันใกล้ก่อน) — คู่สัญญาณแรงชนะก่อนเสมอ
  pairs.sort((a, b) => b.score - a.score || a.dd - b.dd);
  const usedM = new Set(), usedL = new Set(), matched = [];
  pairs.forEach(pr => {
    if (usedM.has(pr.mi) || usedL.has(pr.li)) return;
    usedM.add(pr.mi); usedL.add(pr.li);
    const m = moves[pr.mi];
    // ยอดเท่ากันในกรอบวันมีกี่ตัว — >1 และไม่มีสัญญาณ = เดาจากวัน+ยอดล้วน เสี่ยงสลับ
    const eqCount = stm.filter(l => Math.abs(l.amount - m.amount) <= tol
      && m.bkDate && l.date && brDateDiff(m.bkDate, l.date) <= win).length;
    const ambiguous = !pr.strong && eqCount > 1;
    matched.push({ mango: m, stm: stm[pr.li], score: pr.score, via: pr.via, strong: pr.strong, ambiguous });
  });
  const bookOnly = moves.filter((m, i) => !usedM.has(i));
  const stmOnly  = stm.filter((l, i) => !usedL.has(i));
  const stats = {
    matched: matched.length, bookOnly: bookOnly.length, stmOnly: stmOnly.length, moves: moves.length,
    ambiguous: matched.filter(x => x.ambiguous).length,
    bookOnlyAmt: bookOnly.reduce((s, m) => s + Math.abs(m.amount), 0),
    stmOnlyAmt: stmOnly.reduce((s, l) => s + Math.abs(l.amount), 0),
  };
  return { matched, bookOnly, stmOnly, stats };
}

// ─── สี/ป้ายแบงก์บนหัวบัญชี ────────────────────────────────────────────────────
function brBrandChip(acct) {
  const b = bdBrand(brBrandKey(acct));
  return (
    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, fontWeight: 700, color: '#fff',
      background: b.color, padding: '3px 9px', borderRadius: 6 }}>
      {b.label} <span style={{ opacity: .85, fontWeight: 500 }}>···{bdLast4(acct.accountNo)}</span>
    </span>
  );
}

// ════════════════════════════════════════════════════════════════════════════
function BankReconPage({ data, setData, toast }) {
  const readOnly = typeof _wtpRoleIsReadOnly === 'function' && _wtpRoleIsReadOnly();
  const accounts = brMemo(() => (data.bankAccounts || []).map(bdAcct)
    .filter(a => a.accountNo && (a.type || '').toLowerCase() !== 'closed' && (a.type || '').toLowerCase() !== 'dormant'), [data.bankAccounts]);

  const [accountNo, setAccountNo] = brState('');
  const today = new Date();
  const [month, setMonth] = brState(`${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`);
  const [linesAll, setLinesAll]   = brState(() => BankReconStore.getLines());
  const [mapAll, setMapAll]       = brState(() => BankReconStore.getMapping());
  const [reconState, setReconState] = brState(() => BankReconStore.getState());
  const [bookAll, setBookAll]       = brState(() => BankReconStore.getBook());    // Mango book (งบกระทบยอด) nested by acct/ym
  const [matches, setMatches]       = brState(() => BankReconStore.getMatches()); // Mango↔STM matches (confirmed/manual)
  const [importOpen, setImportOpen] = brState(false); // popup นำเข้าหลายไฟล์ (โยนไฟล์ + สรุป)
  const [recordLine, setRecordLine] = brState(null); // line ในถัง missing → modal บันทึกจ่ายจริง
  const [matchLine, setMatchLine]   = brState(null); // line → modal จับคู่ PV เอง
  const [mainTab, setMainTab]       = brState('forecast'); // หน้าย่อย: 'forecast' (PV/ใบรับ) | 'mango' (Mango ERP ↔ STM)

  // default account = อันแรก
  brEffect(() => { if (!accountNo && accounts.length) setAccountNo(accounts[0].accountNo); }, [accounts]);
  const acct = accounts.find(a => a.accountNo === accountNo) || accounts[0] || null;

  // ── Hydrate จาก cloud (synced entity bankReconLines/State) → cloud ชนะ, อัป localStorage cache ──
  //    push ตอนเขียน (import/ลบ/บันทึก) ไปที่ data ผ่าน setData → sync ขึ้นชีตเอง
  const brCloudSig = brRef(null);
  brEffect(() => {
    // กรองเฉพาะแถวที่เป็น recon จริง — กัน gviz อ่านแท็บผิด (เช่นแท็บ bankReconLines ยังไม่ถูกสร้าง
    //   → คืนชีตแรกมาเป็นขยะ) มาทับ/ล้าง cache ในเครื่อง
    const rows   = (data.bankReconLines || []).filter(r => r && r.accountNo && r.ym);
    const stRows = (data.bankReconState || []).filter(r => r && r.id != null && r.id !== '' && r.decision);
    if (!rows.length && !stRows.length) return;          // ยังไม่มีข้อมูล recon จริงจาก cloud → คง local cache
    const sig = JSON.stringify(rows) + '~' + JSON.stringify(stRows);
    if (sig === brCloudSig.current) return;              // ไม่เปลี่ยน → ข้าม (กัน setState ซ้ำ)
    brCloudSig.current = sig;
    const nested = brRowsToLines(rows);
    const stMap  = brRowsToState(stRows);
    setLinesAll(nested);   BankReconStore.setLines(nested);
    setReconState(stMap);  BankReconStore.setState(stMap);
  }, [data.bankReconLines, data.bankReconState]);

  // ── Hydrate Mango book/match จาก cloud (bankReconBook/Match) → cloud ชนะ + อัป localStorage cache ──
  const brBookSig = brRef(null);
  brEffect(() => {
    const bookRows  = (data.bankReconBook  || []).filter(r => r && r.accountNo && r.ym);
    const matchRows = (data.bankReconMatch || []).filter(r => r && r.id != null && r.id !== '' && r.accountNo);
    if (!bookRows.length && !matchRows.length) return;   // ยังไม่มีข้อมูลจริงจาก cloud → คง local cache
    const sig = JSON.stringify(bookRows) + '~' + JSON.stringify(matchRows);
    if (sig === brBookSig.current) return;
    brBookSig.current = sig;
    const nb = brRowsToBook(bookRows);
    const nm = brRowsToMatch(matchRows);
    setBookAll(nb);  BankReconStore.setBook(nb);
    setMatches(nm);  BankReconStore.setMatches(nm);
  }, [data.bankReconBook, data.bankReconMatch]);

  // push ขึ้น cloud (entity table) — เก็บ localStorage เป็น cache ควบคู่
  const pushReconLines = (nested) => { if (setData) setData(d => ({ ...d, bankReconLines: brLinesToRows(nested) })); };
  const pushReconState = (stMap)  => { if (setData) setData(d => ({ ...d, bankReconState: brStateToRows(stMap) })); };
  const pushReconBook  = (nb)     => { if (setData) setData(d => ({ ...d, bankReconBook:  brBookToRows(nb) })); };
  const pushReconMatch = (nm)     => { if (setData) setData(d => ({ ...d, bankReconMatch: brMatchToRows(nm) })); };

  // ── นำเข้าไฟล์งบกระทบยอด Mango (book) เข้าบัญชี+เดือนที่เลือก → deterministic id + relink matches + persist ──
  const importMangoBook = (parsed, overrideAccNo) => {
    const accNo = overrideAccNo || (acct && acct.accountNo), ym = month;
    if (readOnly || !accNo || !parsed || !parsed.ok) return;
    const moves = brAssignBookIds(parsed.movements || [], accNo, ym);
    const outs  = (parsed.outstanding || []).map((o, i) => Object.assign({}, o, { id: accNo + '|' + ym + '|out#' + i }));
    const meta  = { opening: parsed.opening, bankAsDate: parsed.bankAsDate, bankAsDateBal: parsed.bankAsDateBal };
    const oldMoves = ((bookAll[accNo] || {})[ym] || {}).moves || [];
    const scoped = matches.filter(m => m.accountNo === accNo && m.ym === ym);       // relink เฉพาะบัญชี+เดือนนี้
    const rest   = matches.filter(m => !(m.accountNo === accNo && m.ym === ym));
    const rl = brRelinkMatches(scoped, oldMoves, moves);
    const nm = rest.concat(rl.matches);
    const nb = Object.assign({}, bookAll, { [accNo]: Object.assign({}, bookAll[accNo] || {}, { [ym]: { meta: meta, moves: moves, outs: outs } }) });
    // ★ ทับให้สมบูรณ์: ลบ row เก่าของบัญชี+เดือนนี้ที่ไม่มีในชุดใหม่ ผ่าน forceDeleteRows (ข้ามเกราะ mass-delete)
    //   ไม่งั้น forceSyncNow อาจถูกเกราะบล็อกการลบ → รายการเก่า (คนละธนาคาร) ค้างบน server → พอ reload มา
    //   merge ปนกับของใหม่ = "ข้อมูล 2 ธนาคารปนกัน วางทับแล้วไม่หาย"
    const oldSlot = (bookAll[accNo] || {})[ym];
    const newRowIds = {}; brBookToRows({ [accNo]: { [ym]: { meta: meta, moves: moves, outs: outs } } }).forEach(r => { newRowIds[r.id] = true; });
    const staleIds = oldSlot ? brBookToRows({ [accNo]: { [ym]: oldSlot } }).map(r => r.id).filter(id => !newRowIds[id]) : [];
    setBookAll(nb); BankReconStore.setBook(nb); pushReconBook(nb);
    if (rl.relinked || rl.orphaned) { setMatches(nm); BankReconStore.setMatches(nm); pushReconMatch(nm); }
    if (window.WTPData && typeof WTPData.forceDeleteRows === 'function' && staleIds.length)
      WTPData.forceDeleteRows('bankReconBook', staleIds);
    if (window.WTPData && typeof WTPData.forceSyncNow === 'function')
      WTPData.forceSyncNow(Object.assign({}, data, { bankReconBook: brBookToRows(nb), bankReconMatch: brMatchToRows(nm) }));
    if (toast) toast('นำเข้างบกระทบยอด Mango ' + moves.length + ' รายการ'
      + (rl.relinked ? ' · คงการจับคู่ ' + rl.relinked : '') + (rl.orphaned ? ' · ⚠ ' + rl.orphaned + ' การจับคู่ต้องตรวจใหม่' : ''));
  };

  // ── ยืนยันการจับคู่ (จาก suggestion สด) → เขียน match record status='confirmed' (forceSyncNow) ──
  const confirmMatches = (suggestions, accNo, ym) => {
    if (readOnly || !suggestions || !suggestions.length) return;
    const nowISO = new Date().toISOString();
    const add = suggestions.map(s => ({
      id: (window.WTPData && WTPData.newId) ? WTPData.newId() : ('m-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)),
      accountNo: accNo, ym: ym, bookIds: [s.mango.id], stmIds: [s.stm.id],
      status: 'confirmed', groupId: '', confidence: s.confidence || 'suggested', reason: 'auto', confirmedAt: nowISO,
    }));
    const nm = matches.concat(add);
    setMatches(nm); BankReconStore.setMatches(nm); pushReconMatch(nm);
    if (window.WTPData && typeof WTPData.forceSyncNow === 'function')
      WTPData.forceSyncNow(Object.assign({}, data, { bankReconMatch: brMatchToRows(nm) }));
    if (toast) toast('ยืนยันการจับคู่ ' + add.length + ' รายการ');
  };

  // ── จับคู่เอง (M-to-N) — เลือก book N + stm M → ตรวจผลรวมตรง (±0.01) → record status='manual' ──
  const manualLinkMango = (bookRows, stmRows, accNo, ym) => {
    if (readOnly) return false;
    if (!bookRows.length || !stmRows.length) { if (toast) toast('เลือกทั้งฝั่ง Mango และ STM อย่างน้อยฝั่งละ 1 รายการ'); return false; }
    const bSum = bookRows.reduce((s, b) => s + (Number(b.amount) || 0), 0);
    const sSum = stmRows.reduce((s, l) => s + (Number(l.amount) || 0), 0);
    if (Math.abs(bSum - sSum) >= 0.01) { if (toast) toast('ผลรวมไม่ตรง: Mango ' + fmtNum(bSum, 2) + ' ≠ STM ' + fmtNum(sSum, 2) + ' — จับคู่ไม่ได้'); return false; }
    const isGroup = bookRows.length > 1 || stmRows.length > 1;
    const rec = {
      id: (window.WTPData && WTPData.newId) ? WTPData.newId() : ('m-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)),
      accountNo: accNo, ym: ym, bookIds: bookRows.map(b => b.id), stmIds: stmRows.map(l => l.id),
      status: 'manual', groupId: isGroup ? ('g-' + Date.now().toString(36)) : '', confidence: 'manual', reason: 'manual', confirmedAt: new Date().toISOString(),
    };
    const nm = matches.concat([rec]);
    setMatches(nm); BankReconStore.setMatches(nm); pushReconMatch(nm);
    if (window.WTPData && typeof WTPData.forceSyncNow === 'function')
      WTPData.forceSyncNow(Object.assign({}, data, { bankReconMatch: brMatchToRows(nm) }));
    if (toast) toast('จับคู่เองแล้ว' + (isGroup ? ' (กลุ่ม ' + bookRows.length + ':' + stmRows.length + ')' : ''));
    return true;
  };

  // ── ยกเลิกการจับคู่ — ลบ match record จริงผ่าน forceDeleteRows (ข้ามเกราะ mass-delete) ──
  const unmatchMango = (matchId) => {
    if (readOnly || !matchId) return;
    if (!window.confirm('ยกเลิกการจับคู่นี้? รายการจะกลับไปอยู่ถังรอกระทบ')) return;
    const nm = matches.filter(m => m.id !== matchId);
    setMatches(nm); BankReconStore.setMatches(nm); pushReconMatch(nm);
    if (window.WTPData && typeof WTPData.forceDeleteRows === 'function') WTPData.forceDeleteRows('bankReconMatch', matchId);
    if (toast) toast('ยกเลิกการจับคู่แล้ว');
  };

  // ── ยกเลิกการจับคู่ทีละหลายรายการ — ลบ match record หลายตัวใน call เดียว (ยืนยันครั้งเดียว) ──
  //   ไว้แก้เคส auto-match สลับคู่ (จับด้วยยอด+วัน ไม่ได้อ่าน PV) ที่กดยืนยันไปแล้วหลายรายการ
  const unmatchManyMango = (matchIds) => {
    if (readOnly) return;
    const ids = (matchIds || []).filter(Boolean);
    if (!ids.length) return;
    if (!window.confirm('ยกเลิกการจับคู่ ' + ids.length + ' รายการ? รายการจะกลับไปอยู่ถังรอกระทบ')) return;
    const del = {}; ids.forEach(id => { del[id] = true; });
    const nm = matches.filter(m => !del[m.id]);
    setMatches(nm); BankReconStore.setMatches(nm); pushReconMatch(nm);
    if (window.WTPData && typeof WTPData.forceDeleteRows === 'function') WTPData.forceDeleteRows('bankReconMatch', ids);
    if (toast) toast('ยกเลิกการจับคู่ ' + ids.length + ' รายการแล้ว');
  };

  // ── Self-heal: กู้แถว "บันทึกจ่ายจริง" (BANK_RECON) ที่หายจาก forecastEntries กลับมา ──────────
  //   ปัญหาเดิม: รายการที่กดบันทึกจ่ายจริงหน้านี้ (เขียนเป็น forecastEntries STATUS=ACTUAL → เด้งเข้า
  //   Actual หน้า Weekly Forecast) เกิดบนเครื่องเดียว พอตาราง forecastEntries โดน clobber/wedge
  //   ข้ามไคลเอนต์ (state คนอื่นเก่ากว่า ดันทับ) แถวพวกนี้โดนลบทีละไม่กี่แถว (ต่ำกว่าเกณฑ์ guard)
  //   แล้วไม่มีใครมีให้กู้กลับ → ยอดหายจาก Weekly Forecast.
  //   reconState (ตารางแยก โดน clobber น้อยกว่า) ยังเก็บ decision='recorded' + forecastId ไว้ →
  //   ถ้า forecastId นั้นหายจาก forecastEntries ให้สร้างแถวขึ้นใหม่ด้วย "forecastId เดิม" (idempotent)
  //   จาก bankReconLines (หรือถอด accountNo|date|amount จากตัว line.id) → เด้งเข้า Actual อีกครั้ง.
  //   ทำเฉพาะคนที่แก้ได้ (readOnly ไม่ push); คู่กับเกราะ protect-ACTUAL ฝั่งเซิร์ฟเวอร์ พอกู้แล้วอยู่ถาวร.
  const brHealRef   = brRef('');
  const brUndoneRef = brRef(null); if (!brUndoneRef.current) brUndoneRef.current = {};   // forecastId ที่เพิ่งกด undo → กัน self-heal สร้างกลับ
  brEffect(() => {
    if (readOnly || !setData) return;
    const fes = data.forecastEntries;
    if (!Array.isArray(fes) || !fes.length) return;                    // ยังไม่โหลด forecastEntries จริง → รอ
    const recorded = (data.bankReconState || [])
      .filter(r => r && r.decision === 'recorded' && r.forecastId);
    if (!recorded.length) return;
    const feIds = new Set(fes.map(f => String(f.id)));
    const missing = recorded.filter(s => !feIds.has(String(s.forecastId)) && !brUndoneRef.current[String(s.forecastId)]);
    if (!missing.length) { brHealRef.current = ''; return; }            // ครบแล้ว → รีเซ็ต (ให้กู้ใหม่ได้ถ้าโดนลบรอบหน้า)
    const lineById = {};
    (data.bankReconLines || []).forEach(l => { if (l && l.id != null) lineById[String(l.id)] = l; });
    const todayISO = new Date().toISOString().slice(0, 10);
    // ★ มี PV รองรับอยู่แล้วไหม — ถ้ามี (ยอดตรง ±0.01 + บัญชีตรง + วันใกล้กัน ±31) "ห้าม resurrect"
    //   เพราะ PV เป็น Actual ในระบบอยู่แล้ว → ปั๊ม BANK_RECON ซ้ำ = นับซ้ำหน้า Cashflow.
    //   (เคสเลขเอกสาร PV เปลี่ยน → auto-match พลาด → เคยกดบันทึกจ่ายจริง/จับคู่ PV แล้ว แต่ decision
    //    เด้งกลับ recorded + forecastId ถูกลบ → self-heal เดิมปั๊ม F กลับวนไม่จบ = ยอดคุณกฤตวัฒน์ซ้ำ)
    const pvList = (data.pvVouchers || []).map(bdNormPV).filter(p => p.amount > 0);
    const lineHasPv = (line) => {
      const amt = Math.abs(Number(line.amount) || 0);
      if (!amt) return false;
      return pvList.some(p => {
        if (Math.abs(p.amount - amt) > 0.01) return false;                                                              // ยอดต้องตรง (สัญญาณหลัก)
        if (line.accountNo && p.bankAc && bdDigits(p.bankAc) && !bdAcctMatchesCheck(line.accountNo, p.bankAc)) return false;  // เทียบบัญชีได้แล้วไม่ตรง → คนละก้อน
        if (line.date && p.date && brDateDiff(line.date, p.date) > 31) return false;                                    // วันต้องใกล้กัน
        return true;
      });
    };
    const add = []; let skippedPv = 0;
    missing.forEach(s => {
      const lineId = String(s.id);
      let line = lineById[lineId];
      if (!line) {                                                      // ไม่มี line ในเครื่อง → ถอดจาก id: accountNo|date|amount|ref#n
        const parts = lineId.split('|');
        if (parts.length < 3) return;
        const amt = Number(parts[2]);
        if (!isFinite(amt) || !amt) return;
        line = { accountNo: parts[0], date: parts[1], amount: amt, desc: '', ref: String(parts[3] || '').replace(/#\d+$/, '') };
      }
      const amount = -Math.abs(Number(line.amount) || 0);
      if (!amount) return;
      if (lineHasPv(line)) { skippedPv++; return; }                     // ★ มี PV รองรับ → ไม่ปั๊มซ้ำ (กันนับซ้ำกับ PV)
      add.push({
        id: s.forecastId, DATE: todayISO, PAYMENT_DATE: line.date, EXPENSE_TYPE: 'BANK_RECON',
        DESCRIPTION: line.desc || ('รายการธนาคาร' + (line.ref ? ' ' + line.ref : '')),
        AMOUNT: String(amount), Bank_AC: line.accountNo || line.bankAcct || null,
        STATUS: 'ACTUAL', CATEGORY: null,
        ACTUAL_AMOUNT: String(amount), ACTUAL_DATE: line.date,
        REF_DOC: line.ref || null, BOOKED_AT: null, CFS_ACTIVITY: null,
        NOTE: 'กู้คืนอัตโนมัติจากกระทบยอด (self-heal)',
      });
    });
    if (skippedPv) console.info('[BankRecon] self-heal ข้าม ' + skippedPv + ' รายการ (มี PV รองรับอยู่แล้ว — กันนับซ้ำหน้า Cashflow)');
    if (!add.length) return;
    const sig = add.map(r => r.id).sort().join(',');
    if (sig === brHealRef.current) return;                             // กันยิงซ้ำชุดเดิมระหว่างรอ push/poll
    brHealRef.current = sig;
    setData(d => {
      const have = new Set((d.forecastEntries || []).map(f => String(f.id)));
      const fresh = add.filter(r => !have.has(String(r.id)));
      if (!fresh.length) return d;
      return { ...d, forecastEntries: [...(d.forecastEntries || []), ...fresh] };
    });
    if (toast) toast('กู้คืน ' + add.length + ' รายการจ่ายจริง (กระทบยอด) ที่หายจากการ sync → เด้งเข้า Actual');
  }, [data.forecastEntries, data.bankReconState, data.bankReconLines, data.pvVouchers]);

  // PV ของบัญชี+เดือนนี้ (outflow) — ผูกบัญชีด้วย bdAcctMatchesCheck (เลขท้าย 4)
  const pvForAcct = brMemo(() => {
    if (!acct) return [];
    return (data.pvVouchers || []).map(bdNormPV)
      .filter(p => p.amount > 0 && p.date && brMonthOf(p.date) === month && bdAcctMatchesCheck(acct.accountNo, p.bankAc));
  }, [data.pvVouchers, acct && acct.accountNo, month]);

  // PV ทั้งระบบที่มีเลขที่ PV (ทุกบัญชี/ทุกเดือน) — ให้ pass 0 จับด้วยเลข PV ที่ statement อ้างถึงตรงๆ
  const pvRefPool = brMemo(() => (data.pvVouchers || []).map(bdNormPV).filter(p => p.amount > 0 && p.pvNo), [data.pvVouchers]);

  const lines = brMemo(() => ((linesAll[accountNo] || {})[month]) || [], [linesAll, accountNo, month]);
  const monthly = brMemo(() => brMonthlyView(lines), [lines]);
  const recon = brMemo(() => brReconcile({ lines, pvs: pvForAcct, refPool: pvRefPool, reconState }), [lines, pvForAcct, pvRefPool, reconState]);

  // ── ฝั่งรับ (AR): ใบรับเงินของบัญชี+เดือนนี้ ↔ เงินเข้าจริงใน statement ──────────
  //   "เงินเข้าที่ระบบบันทึก" = receipts (ใบรับเงิน) ที่ receiptDate ตกเดือนนี้ + บัญชีตรง
  //   (receipt.bankAccount มีเลขบัญชี → เทียบเลขท้าย 4; ไม่มีเลข → รวมไว้ เทียบยอด/วัน)
  const receiptsForAcct = brMemo(() => {
    if (!acct) return [];
    return (data.receipts || []).filter(r => {
      const d = brRcDate(r);
      if (!d || brMonthOf(d) !== month) return false;
      const ba = brRcBank(r);
      if (ba && bdDigits(ba).length >= 4) return bdAcctMatchesCheck(acct.accountNo, ba);
      return true;   // ใบรับไม่ระบุบัญชี → เทียบยอด/วันอย่างเดียว
    });
  }, [data.receipts, acct && acct.accountNo, month]);
  const arRecon = brMemo(() => brReconcileAr({ inLines: recon.inLines || [], receipts: receiptsForAcct }), [recon.inLines, receiptsForAcct]);

  // เดือนที่มีข้อมูล statement ของบัญชีนี้ (ไว้สลับเร็ว)
  const monthsWithData = brMemo(() => Object.keys(linesAll[accountNo] || {}).sort().reverse(), [linesAll, accountNo]);

  // ── Import flow ── (popup โยนไฟล์หลายไฟล์ + สรุป → BRImportModal; commit ทีเดียว + re-link)
  //   หา brand+mapping ของไฟล์: ที่เคยตั้ง (mapAll) → preset → เดาจาก header. ใช้โดย BRImportModal.
  const buildMappingFor = (aoa, targetAcct) => {
    const brand = brBrandKey(targetAcct);
    const saved = mapAll[brand];
    const guessed = brGuessHeaderRow(aoa);   // หา header เองเสมอ (กัน index เพี้ยนจาก blank-row)
    let mapping;
    if (saved) mapping = { ...saved };                                            // ที่เคยตั้งไว้ (จำ header row ด้วย)
    else if (BR_PRESETS[brand]) mapping = { ...BR_PRESETS[brand], headerRow: guessed };  // preset (คอลัมน์) + header ที่ตรวจเจอจริง
    else mapping = { ...brAutoMapping(aoa[guessed] || []), headerRow: guessed };
    if (mapping.headerRow == null) mapping.headerRow = guessed;
    return { brand, mapping };
  };
  // นำเข้าไฟล์ที่อ่านได้ทั้งหมดทีเดียว (จาก popup) — group ตามบัญชี + re-link สถานะเก่า + push
  //   items = [{ accountNo, brand, mapping, lines, months, name }] (เฉพาะ status='ready')
  const commitImport = (items) => {
    if (readOnly || !items || !items.length) return;
    let nl = { ...linesAll }, ns = { ...reconState }, nm = { ...mapAll };
    let totalLines = 0, relMoved = 0;
    const byAcct = {};
    items.forEach(it => { (byAcct[it.accountNo] = byAcct[it.accountNo] || []).push(it); if (it.brand && it.mapping) nm[it.brand] = it.mapping; });
    Object.keys(byAcct).forEach(accNo => {
      const bucket = { ...(nl[accNo] || {}) };
      byAcct[accNo].forEach(it => {
        const byMonth = {};
        it.lines.forEach(l => { (byMonth[brMonthOf(l.date)] = byMonth[brMonthOf(l.date)] || []).push(l); });
        Object.keys(byMonth).forEach(m => {
          const r = brRelinkState(ns, bucket[m] || [], byMonth[m]);   // ★ ย้ายสถานะเก่า (แมต/โอน/จ่ายจริง) มาให้รายการใหม่
          ns = r.ns; relMoved += r.moved;
          bucket[m] = byMonth[m]; totalLines += byMonth[m].length;    // ทับเฉพาะเดือนที่อยู่ในไฟล์
        });
      });
      nl[accNo] = bucket;
    });
    setLinesAll(nl); BankReconStore.setLines(nl); pushReconLines(nl);
    setMapAll(nm);   BankReconStore.setMapping(nm);
    if (relMoved) { setReconState(ns); BankReconStore.setState(ns); pushReconState(ns); }
    const last = items[items.length - 1];                             // สลับไปดูบัญชี/เดือนล่าสุดที่นำเข้า
    if (last) { setAccountNo(last.accountNo); if (last.months && last.months.length) setMonth(last.months[last.months.length - 1]); }
    setImportOpen(false);
    if (toast) toast(`นำเข้า ${items.length} ไฟล์ · ${totalLines} รายการ` + (relMoved ? ` · คงสถานะที่เคยทำไว้ ${relMoved} รายการ` : ''));
  };
  const clearMonth = () => {
    if (!acct || !window.confirm(`ลบ statement ของ ${brFmtMonth(month)} บัญชีนี้?`)) return;
    const acctBucket = { ...(linesAll[acct.accountNo] || {}) }; delete acctBucket[month];
    const nl = { ...linesAll, [acct.accountNo]: acctBucket }; setLinesAll(nl); BankReconStore.setLines(nl); pushReconLines(nl);
  };

  // ── บันทึก "เกิดจริงแต่ยังไม่ลงระบบ" → forecastEntries STATUS=ACTUAL (เด้งเข้า Cashflow) ──
  const recordActual = (line, cat) => {
    if (readOnly) { if (toast) toast('สิทธิ์นี้ดูได้อย่างเดียว'); return; }
    const id = (window.WTPData && WTPData.newId) ? WTPData.newId() : ('fe-' + Date.now());
    const todayISO = new Date().toISOString().slice(0, 10);
    const row = {
      id, DATE: todayISO, PAYMENT_DATE: line.date, EXPENSE_TYPE: 'BANK_RECON',
      DESCRIPTION: line.desc || ('รายการธนาคาร' + (line.ref ? ' ' + line.ref : '')),
      AMOUNT: String(-Math.abs(line.amount)), Bank_AC: line.bankAcct || (acct && acct.accountNo) || null,
      STATUS: 'ACTUAL', CATEGORY: cat ? String(cat) : null,
      ACTUAL_AMOUNT: String(-Math.abs(line.amount)), ACTUAL_DATE: line.date,
      REF_DOC: line.ref || null, BOOKED_AT: null, CFS_ACTIVITY: null, NOTE: 'นำเข้าจาก statement (กระทบยอด)',
    };
    setData(d => ({ ...d, forecastEntries: [...(d.forecastEntries || []), row] }));
    const ns = { ...reconState, [line.id]: { decision: 'recorded', forecastId: id } };
    setReconState(ns); BankReconStore.setState(ns); pushReconState(ns);
    setRecordLine(null);
    if (toast) toast('บันทึกจ่ายจริงแล้ว → เด้งเข้า Actual หน้า Cashflow');
  };
  const undoRecord = (line, forecastId) => {
    if (readOnly) return;
    // ★ ลบรายการ "จ่ายจริง" (ACTUAL) ออกจาก Cashflow แบบถาวร + sync ทั้งทีม → ยืนยันก่อน
    if (!window.confirm('ยกเลิกการบันทึกจ่ายจริงรายการนี้?\nจะลบออกจาก Actual หน้า Cashflow และมีผลกับทั้งทีม')) return;
    let nextFe = data.forecastEntries || [];
    if (forecastId) {
      brUndoneRef.current[String(forecastId)] = true;                 // ★ กัน self-heal สร้างแถวนี้กลับ (เจตนาลบ)
      nextFe = nextFe.filter(e => e.id !== forecastId);
      setData(d => ({ ...d, forecastEntries: (d.forecastEntries || []).filter(e => e.id !== forecastId) }));
      // แถวนี้ STATUS=ACTUAL → diff ลบปกติจะโดนเกราะ protect-ACTUAL ฝั่งเซิร์ฟเวอร์บล็อก (เด้งกลับ)
      //   ดังนั้นสั่งลบจริงด้วย forceDeleteRows (allowShrink) — เป็นการลบที่ผู้ใช้ตั้งใจ
      if (window.WTPData && typeof WTPData.forceDeleteRows === 'function') {
        WTPData.forceDeleteRows('forecastEntries', forecastId);
      }
    }
    const ns = { ...reconState }; delete ns[line.id];
    setReconState(ns); BankReconStore.setState(ns); pushReconState(ns);
    // ★ persist ทันที (ไม่รอ debounce) — กัน decision หาย แล้ว self-heal ปั๊มแถวกลับ
    if (window.WTPData && typeof WTPData.forceSyncNow === 'function') {
      WTPData.forceSyncNow({ ...data, forecastEntries: nextFe, bankReconState: brStateToRows(ns) });
    }
    if (toast) toast('ยกเลิกบันทึก — ลบออกจาก Actual แล้ว');
  };

  // ── จับคู่ PV เอง (manual match) — เคสเลขเอกสารเปลี่ยน auto-match พลาด ──────────
  //   ผูก statement line ↔ PV ที่เลือก: ทั้งคู่ออกจากถังค้าง ไปอยู่ "แมตช์แล้ว (จับคู่เอง)"
  //   ★ ทนทานกว่ากด "ยกเลิก": ถ้าบรรทัดนี้เคยกด "บันทึกจ่ายจริง" มาก่อน (decision='recorded')
  //     จะ "เปลี่ยน decision เป็น matched" (ไม่ใช่ลบทิ้ง) → self-heal เลิกสร้างแถว BANK_RECON
  //     กลับมา (มันกู้เฉพาะ decision='recorded') + ไม่นับ Actual ซ้ำกับ PV
  const manualMatchPv = (line, pv) => {
    if (readOnly) { if (toast) toast('สิทธิ์นี้ดูได้อย่างเดียว'); return; }
    if (!pv) return;
    const prev = reconState[line.id];
    let nextFe = data.forecastEntries || [];
    if (prev && prev.decision === 'recorded' && prev.forecastId) {
      brUndoneRef.current[String(prev.forecastId)] = true;             // กัน self-heal สร้างแถวจ่ายจริงกลับ
      nextFe = nextFe.filter(e => e.id !== prev.forecastId);
      setData(d => ({ ...d, forecastEntries: (d.forecastEntries || []).filter(e => e.id !== prev.forecastId) }));
      if (window.WTPData && typeof WTPData.forceDeleteRows === 'function') WTPData.forceDeleteRows('forecastEntries', prev.forecastId);
    }
    const pvRef = (pv.id != null && pv.id !== '') ? String(pv.id) : (pv.pvNo || '');
    const ns = { ...reconState, [line.id]: { decision: 'matched', forecastId: '', pvRef } };
    setReconState(ns); BankReconStore.setState(ns); pushReconState(ns);
    // ★ persist ทันที (ไม่รอ debounce 1.5s) — กัน decision='matched' หายระหว่างทาง แล้วเหลือ
    //   recorded + F ถูกลบ → self-heal ปั๊ม F กลับ → นับซ้ำ. push ด้วย data ที่ตัด F ออกแล้ว (กัน upsert F คืน)
    if (window.WTPData && typeof WTPData.forceSyncNow === 'function') {
      WTPData.forceSyncNow({ ...data, forecastEntries: nextFe, bankReconState: brStateToRows(ns) });
    }
    setMatchLine(null);
    if (toast) toast('จับคู่ PV เองแล้ว — ไม่สร้างรายการ Actual ซ้ำ (PV เป็น Actual อยู่แล้ว)');
  };

  // ── ทำเครื่องหมาย "โอนระหว่างบัญชี" — ออกจากถังขาดบันทึก โดยไม่สร้าง Actual (ไม่ใช่รายจ่าย) ──
  const markTransfer = (line) => {
    if (readOnly) { if (toast) toast('สิทธิ์นี้ดูได้อย่างเดียว'); return; }
    const ns = { ...reconState, [line.id]: { decision: 'transfer', forecastId: '' } };
    setReconState(ns); BankReconStore.setState(ns); pushReconState(ns);
    if (toast) toast('ทำเครื่องหมาย "โอนระหว่างบัญชี" — ไม่นับเป็นรายจ่าย');
  };

  // ── "แถวผี" = forecastEntries BANK_RECON ที่ไม่มี reconState entry ไหนชี้หา (id ไม่ใช่ forecastId
  //    ปัจจุบันของใคร) + มี "ตัวจริง" (live twin) content เดียวกันอยู่ → ตกค้างจากการ record ซ้ำ/นำเข้าทับ
  //    → Cashflow นับเบิ้ล. ★ ไม่จับ LG จ่ายซ้ำจริง (คนละบรรทัด statement → คนละ reconState entry → live
  //    ทั้งคู่). ★ ไม่จับ orphan โดดเดี่ยว (ไม่มี twin) เผื่อ reconState หาย — กันลบรายการจริง.
  const brOrphanRows = brMemo(() => {
    const fes = (data.forecastEntries || []).filter(fe => String(fe.EXPENSE_TYPE || '').toUpperCase() === 'BANK_RECON');
    if (!fes.length) return [];
    const validIds = new Set();
    (data.bankReconState || []).forEach(r => { if (r && String(r.decision || '') === 'recorded' && r.forecastId) validIds.add(String(r.forecastId)); });
    if (!validIds.size) return [];
    const keyOf = fe => {
      const d = fe.ACTUAL_DATE || fe.PAYMENT_DATE || fe.DATE;
      const a = Number(fe.ACTUAL_AMOUNT || fe.AMOUNT || fe.amount || 0);
      return String(fe.Bank_AC || '').trim() + '|' + d + '|' + a + '|' + String(fe.DESCRIPTION || '').trim();
    };
    const liveKeys = new Set();
    fes.forEach(fe => { if (validIds.has(String(fe.id))) liveKeys.add(keyOf(fe)); });
    return fes.filter(fe => !validIds.has(String(fe.id)) && liveKeys.has(keyOf(fe)));
  }, [data.forecastEntries, data.bankReconState]);
  const brOrphanTotal = brOrphanRows.reduce((s, r) => s + Math.abs(Number(r.ACTUAL_AMOUNT || r.AMOUNT || 0)), 0);
  const purgeReconOrphans = () => {
    if (readOnly || !brOrphanRows.length) return;
    if (!confirm(`ลบ "แถวผี" (รายการจ่ายจริงที่ระบบสร้างซ้ำ) ${brOrphanRows.length} รายการ รวม ${fmtNum(brOrphanTotal, 2)} ออกถาวร?\n\n• ตัวจริงยังอยู่ครบ — ยอดจ่ายจริงไม่หาย\n• ไม่แตะ LG/ค่าธรรมเนียมที่จ่ายซ้ำจริง (คนละบรรทัด statement)`)) return;
    const ids = brOrphanRows.map(r => r.id);
    if (window.WTPData && typeof WTPData.forceDeleteRows === 'function') {
      WTPData.forceDeleteRows('forecastEntries', ids);   // ข้ามเกราะ protect-ACTUAL (ลบตามเจตนา)
    } else if (setData) {
      const del = new Set(ids.map(String));
      setData(d => ({ ...d, forecastEntries: (d.forecastEntries || []).filter(e => !del.has(String(e.id))) }));
    }
    if (toast) toast(`ลบแถวผี ${ids.length} รายการแล้ว — Cashflow ไม่นับเบิ้ลอีก`);
  };

  // เดาว่าเป็นโอนระหว่างบัญชีตัวเอง: desc มีคำว่าโอน + (ชื่อบริษัทตัวเอง หรือเลขท้ายบัญชีตัวเองในระบบ)
  const ownLast4 = brMemo(() => new Set((data.bankAccounts || []).map(a => bdLast4(bdAcct(a).accountNo)).filter(Boolean)), [data.bankAccounts]);
  const companyKey = (data.meta && (data.meta.shortName || data.meta.companyName)) || '';
  const isLikelyTransfer = (line) => {
    const d = String((line && line.desc) || '');
    if (!/โอน|transfer/i.test(d)) return false;
    if (companyKey && d.indexOf(companyKey) >= 0) return true;
    if (/วอเทอร์ป๊อก|วอเตอร์ป๊อก|water\s*pog/i.test(d)) return true;
    return (d.match(/\d{4}/g) || []).some(x => ownLast4.has(x));
  };

  const goMonth = (delta) => {
    const [y, m] = month.split('-').map(Number);
    const d = new Date(y, m - 1 + delta, 1);
    setMonth(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
  };

  if (!accounts.length) {
    return <div className="page bg-pattern"><div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--ink-500)' }}>ยังไม่มีบัญชีธนาคารในระบบ</div></div>;
  }

  return (
    <div className="page bg-pattern">

      {/* Header */}
      <div className="page-head anim-in">
        <div>
          <h1 className="page-title">กระทบยอดธนาคาร</h1>
          <div className="page-sub">Bank Reconciliation · จ่าย: PV ↔ statement · รับ: เงินเข้า ↔ ใบรับเงิน · {brFmtMonth(month)}</div>
        </div>
        <div className="page-head-r" style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
          <button className="btn btn-ghost" onClick={() => goMonth(-1)} title="เดือนก่อน">‹</button>
          <div style={{ padding: '6px 12px', background: 'var(--ink-50)', borderRadius: 8, fontSize: 13, fontWeight: 600, minWidth: 96, textAlign: 'center' }}>{brFmtMonth(month)}</div>
          <button className="btn btn-ghost" onClick={() => goMonth(1)} title="เดือนถัดไป">›</button>
          {!readOnly && <button className="btn btn-primary" onClick={() => setImportOpen(true)} title="นำเข้าไฟล์รายการเดินบัญชี (CSV/Excel) — โยนได้หลายไฟล์/หลายบัญชีพร้อมกัน">📥 นำเข้า statement</button>}
        </div>
      </div>

      {/* Account selector — ปุ่มแบงค์ กดคลิกเดียวเข้าเลย (เลิกใช้ dropdown) */}
      <div className="card anim-in" style={{ padding: 12, marginBottom: 16 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ink-600)' }}>บัญชี:</span>
          <div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
            {accounts.map(a => {
              const active = a.accountNo === accountNo;
              const b = bdBrand(brBrandKey(a));
              return (
                <button key={a.accountNo} type="button" onClick={() => setAccountNo(a.accountNo)}
                  title={a.accountName || a.bankName || a.accountNo}
                  style={{
                    display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontFamily: 'inherit',
                    border: `2px solid ${active ? b.color : 'var(--line)'}`,
                    background: active ? `color-mix(in oklch, ${b.color} 10%, #fff)` : '#fff',
                    borderRadius: 11, padding: '5px 13px 5px 6px',
                    fontSize: 12.5, fontWeight: active ? 700 : 600,
                    color: active ? b.color : 'var(--ink-600)',
                    boxShadow: active ? `0 2px 10px color-mix(in oklch, ${b.color} 28%, transparent)` : 'none',
                    transition: 'all .14s',
                  }}>
                  <HpBankLogo name={a.bankName} />
                  <span>{b.label} <span style={{ opacity: .7, fontWeight: 500 }}>···{bdLast4(a.accountNo)}</span></span>
                </button>
              );
            })}
          </div>
        </div>
        {monthsWithData.length > 0 && (
          <div style={{ fontSize: 11.5, color: 'var(--ink-500)', marginTop: 10, display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
            <span>มี statement:</span>
            {monthsWithData.map(m => (
              <button key={m} onClick={() => setMonth(m)} style={{ border: 'none', background: m === month ? 'var(--brand-500)' : 'var(--ink-100)', color: m === month ? '#fff' : 'var(--ink-700)', borderRadius: 5, padding: '2px 8px', cursor: 'pointer', fontSize: 11 }}>{brFmtMonth(m)}</button>
            ))}
          </div>
        )}
      </div>

      {/* sub-nav: 2 หน้าย่อย — ใช้บัญชี/เดือน/STM ร่วมกัน (compact) */}
      <div className="card anim-in" style={{ padding: 4, marginBottom: 14, display: 'flex', gap: 5 }}>
        {[
          { key: 'forecast', label: '📥 เทียบ PV / ใบรับเงิน', sub: 'Weekly Forecast — เทียบกับระบบ WTP' },
          { key: 'mango',    label: '📒 เทียบ Mango ERP',      sub: 'งบกระทบยอด ERP ↔ STM — หาลงบัญชี ขาด/เกิน' },
        ].map(t => (
          <button key={t.key} onClick={() => setMainTab(t.key)}
            style={{ flex: '1 1 0', border: 'none', cursor: 'pointer', borderRadius: 8, padding: '6px 10px', textAlign: 'left', fontFamily: 'inherit',
              background: mainTab === t.key ? 'linear-gradient(135deg, var(--brand-500), var(--brand-700))' : 'var(--ink-50)',
              color: mainTab === t.key ? '#fff' : 'var(--ink-700)',
              boxShadow: mainTab === t.key ? '0 3px 10px color-mix(in oklch, var(--brand-500) 28%, transparent)' : 'none', transition: 'all .14s' }}>
            <div style={{ fontSize: 12.5, fontWeight: 700 }}>{t.label}</div>
            <div style={{ fontSize: 10, opacity: mainTab === t.key ? .9 : .6, marginTop: 1 }}>{t.sub}</div>
          </button>
        ))}
      </div>

      {/* 🧹 แถวผี (BANK_RECON ที่ระบบสร้างซ้ำ) → Cashflow นับเบิ้ล — ลบออกจริง (ตัวจริง+LG จ่ายซ้ำไม่โดน) */}
      {!readOnly && brOrphanRows.length > 0 && (
        <div className="card no-print" style={{ padding: '12px 16px', marginBottom: 16, borderLeft: '4px solid var(--bad)',
          background: 'color-mix(in oklch, var(--bad) 5%, var(--surface))', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
          <div style={{ flex: 1, minWidth: 220 }}>
            <div style={{ fontWeight: 700, fontSize: 13, color: 'var(--ink-800)' }}>
              🧹 พบรายการจ่ายจริง "ซ้ำ" (แถวผี) {brOrphanRows.length} รายการ — ทำให้ Cashflow นับเบิ้ล
            </div>
            <div style={{ fontSize: 11.5, color: 'var(--ink-500)', marginTop: 2 }}>
              เกิดจากบันทึกจ่ายจริงซ้ำ/นำเข้าทับ · ตัวจริงยังอยู่ครบ ยอดไม่หาย · ไม่กระทบ LG/ค่าธรรมเนียมที่จ่ายซ้ำจริง · รวม {fmtNum(brOrphanTotal, 2)}
            </div>
          </div>
          <button className="btn" style={{ background: 'var(--bad)', color: '#fff', border: 'none' }} onClick={purgeReconOrphans}>
            <Icon name="trash" size={14} /> ลบแถวผี ({brOrphanRows.length})
          </button>
        </div>
      )}

      {mainTab === 'forecast' && (<React.Fragment>
      {/* KPI — ยกมา / เข้า / ออก / คงเหลือ + cross-check */}
      <div className="grid anim-in" style={{ gridTemplateColumns: 'repeat(4, minmax(0,1fr))', gap: 12, marginBottom: 8 }}>
        <KpiTile label="ยอดยกมา (ต้นเดือน)" value={monthly.opening != null ? monthly.opening : 0} digits={0} accent="var(--brand-500)" icon="coin" />
        <KpiTile label="เงินเข้า" value={monthly.inTotal} digits={0} accent="var(--good)" icon="arrow_down" />
        <KpiTile label="เงินออก" value={monthly.outTotal} digits={0} accent="var(--bad)" icon="arrow_up" />
        <KpiTile label="คงเหลือ (ปลายเดือน)" value={monthly.closing != null ? monthly.closing : 0} digits={0} accent="var(--brand-700)" icon="bank" />
      </div>
      {monthly.count > 0 && monthly.balOK != null && (
        <div style={{ marginBottom: 16, fontSize: 12, fontWeight: 600,
          color: monthly.balOK ? 'var(--good)' : 'var(--bad)' }}>
          {monthly.balOK
            ? '✓ ยอดคงเหลือใน statement ตรงกับผลรวมรายการ'
            : `⚠️ ยอดคงเหลือไม่ตรง (ต่าง ${fmtNum(Math.abs(monthly.closing - monthly.expectedClosing), 2)}) — อาจมีรายการขาด/ซ้ำใน statement`}
        </div>
      )}

      {/* ไม่มี statement → ชวนนำเข้า */}
      {monthly.count === 0 ? (
        <div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--ink-500)', marginBottom: 16 }}>
          <div style={{ fontSize: 40, marginBottom: 8 }}>🏦</div>
          <div style={{ fontWeight: 600, color: 'var(--ink-700)', marginBottom: 4 }}>ยังไม่มีรายการเดินบัญชีของ {brFmtMonth(month)}</div>
          <div style={{ fontSize: 12.5, marginBottom: 14 }}>นำเข้าไฟล์ statement (CSV/Excel) จากธนาคาร แล้วระบบจะกระทบกับ PV ในระบบให้ · เลือกได้หลายไฟล์/หลายบัญชีพร้อมกัน</div>
          {!readOnly && <button className="btn btn-primary" onClick={() => setImportOpen(true)}>📥 นำเข้า statement</button>}
          {pvForAcct.length > 0 && <div style={{ fontSize: 12, marginTop: 14, color: 'var(--ink-600)' }}>เดือนนี้มี PV ในระบบ <b>{pvForAcct.length}</b> รายการ (รวม {fmtNum(recon.stats.unmatchedPvAmt, 0)}) รอกระทบ</div>}
        </div>
      ) : (
        <BRReconcileSection recon={recon} acct={acct} readOnly={readOnly}
          onRecord={setRecordLine} onUndo={undoRecord} onMatch={setMatchLine}
          onMarkTransfer={markTransfer} isLikelyTransfer={isLikelyTransfer} />
      )}

      {/* ฝั่งรับ (AR) — เงินเข้าจริงใน statement ↔ ใบรับเงินในระบบ (ตรวจว่าเงินเข้ามีจริงไหม) */}
      {monthly.count > 0 && <BRArSection arRecon={arRecon} acct={acct} />}

      {/* รายการเดินบัญชี (statement) — ยกมา → เคลื่อนไหว → คงเหลือ */}
      {monthly.count > 0 && <BRStatementTable monthly={monthly} />}
      </React.Fragment>)}

      {/* ── หน้าย่อย 2: เทียบ Mango ERP ↔ STM ── */}
      {mainTab === 'mango' && (
        <BRMangoTab accounts={accounts} accountNo={accountNo} setAccountNo={setAccountNo}
          acct={acct} month={month} stmLines={lines} linesAll={linesAll} bookAll={bookAll} matches={matches}
          readOnly={readOnly} toast={toast}
          onImportBook={importMangoBook} onImportStatement={() => setImportOpen(true)}
          onConfirm={confirmMatches} onManualLink={manualLinkMango} onUnmatch={unmatchMango} onUnmatchMany={unmatchManyMango} />
      )}

      {/* Import modal — popup โยนไฟล์หลายไฟล์ + สรุป → นำเข้าทีเดียว */}
      {importOpen && (
        <BRImportModal accounts={accounts} defaultAcct={acct} buildMapping={buildMappingFor}
          onCommit={commitImport} onClose={() => setImportOpen(false)} toast={toast} />
      )}

      {/* Record-actual modal */}
      {recordLine && (
        <BRRecordModal line={recordLine} onSave={recordActual} onClose={() => setRecordLine(null)} />
      )}

      {/* Manual-match modal — เลือก PV ในระบบมาผูกกับบรรทัด statement เอง */}
      {matchLine && (
        <BRMatchModal line={matchLine} candidates={recon.unmatchedPv || []} allPvs={pvRefPool}
          onMatch={manualMatchPv} onClose={() => setMatchLine(null)} />
      )}

      {/* footer note */}
      <div className="card no-print" style={{ marginTop: 16, padding: '10px 14px', background: '#fffbeb', borderLeft: '4px solid #f6ad55', fontSize: 11.5, color: 'var(--ink-700)', lineHeight: 1.7 }}>
        💡 จับคู่ด้วย <b>ยอด</b> (±0.01) + <b>วันที่</b> (±3 วัน) + เลขเช็ค · รองรับ 1 รายการธนาคาร = หลาย PV ·
        รายการ "เกิดจริงแต่ยังไม่ลงระบบ" กด <b>บันทึกจ่ายจริง</b> → เพิ่มเป็น Actual หน้า <a href="#cashflow" style={{ color: 'var(--brand-600)' }}>Cashflow</a> ·
        statement เก็บในเครื่อง (localStorage) ไม่ขึ้น cloud
      </div>
    </div>
  );
}

// ─── ส่วนกระทบยอด 3 ถัง ───────────────────────────────────────────────────────
function BRReconcileSection({ recon, acct, readOnly, onRecord, onUndo, onMatch, onMarkTransfer, isLikelyTransfer }) {
  const [tab, setTab] = brState('missing');
  const { matched, missing, unmatchedPv, recorded, transfers, stats } = recon;
  const tabs = [
    { key: 'missing',  label: 'ขาดบันทึก',         n: missing.length,    color: 'var(--bad)' },
    { key: 'unmatch',  label: 'ยังไม่ออกจริง',      n: unmatchedPv.length, color: 'var(--warn)' },
    { key: 'matched',  label: 'แมตช์แล้ว',          n: matched.length,    color: 'var(--good)' },
    { key: 'transfer', label: 'โอนระหว่างบัญชี',     n: (transfers || []).length, color: 'oklch(60% 0.13 250)' },
    { key: 'recorded', label: 'บันทึกจาก statement', n: recorded.length,   color: 'var(--brand-600)' },
  ];
  return (
    <div className="card anim-in" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
      {/* summary chips / tabs */}
      <div style={{ display: 'flex', borderBottom: '1px solid var(--line)', flexWrap: 'wrap' }}>
        {tabs.map(t => (
          <button key={t.key} onClick={() => setTab(t.key)}
            style={{ flex: '1 1 0', minWidth: 130, border: 'none', background: tab === t.key ? 'var(--surface)' : 'var(--ink-50)',
              borderBottom: tab === t.key ? '2px solid ' + t.color : '2px solid transparent', cursor: 'pointer', padding: '12px 10px', textAlign: 'center' }}>
            <div style={{ fontSize: 22, fontWeight: 800, color: t.color, fontVariantNumeric: 'tabular-nums' }}>{t.n}</div>
            <div style={{ fontSize: 12, color: 'var(--ink-600)', fontWeight: 600 }}>{t.label}</div>
          </button>
        ))}
      </div>
      <div style={{ padding: 14 }}>
        {tab === 'missing'  && <BRMissingTable rows={missing} readOnly={readOnly} onRecord={onRecord} onMatch={onMatch} onMarkTransfer={onMarkTransfer} isLikelyTransfer={isLikelyTransfer} />}
        {tab === 'unmatch'  && <BRPvTable rows={unmatchedPv} />}
        {tab === 'matched'  && <BRMatchedTable rows={matched} readOnly={readOnly} onUndo={onUndo} />}
        {tab === 'transfer' && <BRTransfersTable rows={transfers || []} readOnly={readOnly} onUndo={onUndo} />}
        {tab === 'recorded' && <BRRecordedTable rows={recorded} readOnly={readOnly} onUndo={onUndo} onMatch={onMatch} />}
      </div>
    </div>
  );
}

function BREmpty({ text }) { return <div style={{ padding: 24, textAlign: 'center', color: 'var(--ink-400)', fontSize: 12.5 }}>{text}</div>; }

// ─── ฝั่งรับ (AR) — ตรวจว่า "เงินเข้าที่ระบบดึงมา" มีรายการเกิดขึ้นจริงในธนาคารไหม ───
//   เทียบเงินเข้าจริง (statement credit) ↔ ใบรับเงินในระบบ (receipts). read-only (ดูอย่างเดียว)
function BRArSection({ arRecon, acct }) {
  const [tab, setTab] = brState('receiptNoDeposit');
  const { matched, receiptNoDeposit, depositNoReceipt, stats } = arRecon;
  const tabs = [
    { key: 'receiptNoDeposit', label: 'บันทึกรับ · ยังไม่เจอเงินเข้า', n: receiptNoDeposit.length, color: 'var(--bad)' },
    { key: 'matched',          label: 'ตรงกัน',                       n: matched.length,          color: 'var(--good)' },
    { key: 'depositNoReceipt', label: 'เงินเข้า · ยังไม่มีใบรับ',     n: depositNoReceipt.length, color: 'oklch(60% 0.13 250)' },
  ];
  const bankShort = ba => String(ba || '').split(' ')[0] || '—';
  return (
    <div className="card anim-in" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
      <div style={{ padding: '11px 16px', borderBottom: '1px solid var(--line)', fontWeight: 700, fontSize: 14, color: 'var(--ink-800)', background: 'color-mix(in oklch, var(--good) 5%, transparent)' }}>
        💰 กระทบยอดเงินเข้า (รับจริง)
        <span style={{ fontWeight: 400, fontSize: 11.5, color: 'var(--ink-500)', marginLeft: 8 }}>
          เงินเข้าจริงใน statement ⟷ ใบรับเงินในระบบ — ตรวจว่ายอดที่ดึงมามีเงินเข้าจริงไหม
        </span>
      </div>
      {/* tabs */}
      <div style={{ display: 'flex', borderBottom: '1px solid var(--line)', flexWrap: 'wrap' }}>
        {tabs.map(t => (
          <button key={t.key} onClick={() => setTab(t.key)}
            style={{ flex: '1 1 0', minWidth: 150, border: 'none', background: tab === t.key ? 'var(--surface)' : 'var(--ink-50)',
              borderBottom: tab === t.key ? '2px solid ' + t.color : '2px solid transparent', cursor: 'pointer', padding: '12px 10px', textAlign: 'center' }}>
            <div style={{ fontSize: 22, fontWeight: 800, color: t.color, fontVariantNumeric: 'tabular-nums' }}>{t.n}</div>
            <div style={{ fontSize: 12, color: 'var(--ink-600)', fontWeight: 600 }}>{t.label}</div>
          </button>
        ))}
      </div>
      <div style={{ padding: 14 }}>
        {/* ── ระบบบันทึกรับแล้ว แต่ไม่เจอเงินเข้าจริง (= ยอดที่ดึงมาอาจไม่จริง) ── */}
        {tab === 'receiptNoDeposit' && (
          receiptNoDeposit.length === 0
            ? <BREmpty text="✓ ใบรับเงินทุกใบเจอเงินเข้าจริงใน statement แล้ว" />
            : <div style={{ maxHeight: '52vh', overflow: 'auto' }}>
                <div style={{ fontSize: 12, color: 'var(--ink-600)', marginBottom: 8 }}>
                  ⚠️ ระบบบันทึก<b>รับเงินแล้ว</b> แต่<b>ไม่เจอเงินเข้าจริง</b>ในรายการเดินบัญชีเดือนนี้ — ตรวจว่าเงินเข้าจริงไหม / ยอดถูกไหม / เข้าบัญชีอื่นหรือเดือนอื่น
                </div>
                <table className="tbl" style={{ width: '100%', fontSize: 12.5 }}>
                  <thead style={{ position: 'sticky', top: 0, background: 'var(--surface)', zIndex: 1 }}>
                    <tr><th style={{ width: 96 }}>วันที่รับ</th><th style={{ width: 110 }}>เลขที่ใบรับ</th><th>IV / โครงการ</th><th style={{ width: 90 }}>บัญชี</th><th style={{ width: 130, textAlign: 'right' }}>ยอดที่บันทึก</th></tr>
                  </thead>
                  <tbody>
                    {receiptNoDeposit.map((r, i) => (
                      <tr key={r.id || r.receiptNo || i}>
                        <td style={{ whiteSpace: 'nowrap', color: 'var(--ink-600)' }}>{fmtDate(brRcDate(r)) || brRcDate(r) || '—'}</td>
                        <td style={{ fontFamily: 'ui-monospace', fontSize: 11.5 }}>{r.receiptNo || '—'}</td>
                        <td>
                          <div style={{ fontWeight: 600, color: 'var(--brand-700)' }}>{r.invoiceNo || '—'}</div>
                          <div style={{ fontSize: 11, color: 'var(--ink-500)' }}>{r.projectName || ''}</div>
                        </td>
                        <td style={{ fontSize: 11.5, color: 'var(--ink-600)' }}>{bankShort(brRcBank(r))}</td>
                        <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: 'var(--bad)' }}>
                          {fmtNum(brRcNet(r), 2)}
                          {Number(r.transferDeduction) > 0 && <div style={{ fontSize: 10, color: 'var(--ink-400)', fontWeight: 400 }}>เต็ม {fmtNum(brRcGross(r), 0)} · หักโอน {fmtNum(Number(r.transferDeduction), 0)}</div>}
                        </td>
                      </tr>
                    ))}
                  </tbody>
                  <tfoot><tr style={{ borderTop: '2px solid var(--line)' }}>
                    <td colSpan={4} style={{ textAlign: 'right', fontWeight: 700, color: 'var(--ink-600)', padding: '6px 8px' }}>รวม {receiptNoDeposit.length} ใบ</td>
                    <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 800, color: 'var(--bad)' }}>{fmtNum(stats.receiptNoDepositAmt, 2)}</td>
                  </tr></tfoot>
                </table>
              </div>
        )}
        {/* ── ตรงกัน — เงินเข้าจริง ↔ ใบรับ ── */}
        {tab === 'matched' && (
          matched.length === 0
            ? <BREmpty text="ยังไม่มีรายการเงินเข้าที่จับคู่กับใบรับเงินได้" />
            : <div style={{ maxHeight: '52vh', overflow: 'auto' }}>
                <div style={{ fontSize: 12, color: 'var(--ink-600)', marginBottom: 8 }}>✓ เงินเข้าจริงในธนาคาร ตรงกับใบรับเงินในระบบ (ยอด ±0.01 + วัน ±7 วัน)</div>
                <table className="tbl" style={{ width: '100%', fontSize: 12.5 }}>
                  <thead style={{ position: 'sticky', top: 0, background: 'var(--surface)', zIndex: 1 }}>
                    <tr><th style={{ width: 96 }}>วันเงินเข้า</th><th style={{ width: 130, textAlign: 'right' }}>เงินเข้าจริง</th><th style={{ width: 110 }}>เลขที่ใบรับ</th><th>IV / โครงการ</th><th style={{ width: 70, textAlign: 'center' }}>เทียบ</th></tr>
                  </thead>
                  <tbody>
                    {matched.map((m, i) => (
                      <tr key={m.line.id || i}>
                        <td style={{ whiteSpace: 'nowrap', color: 'var(--ink-600)' }}>{fmtDate(m.line.date) || m.line.date}</td>
                        <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: 'var(--good)' }}>{fmtNum(Math.abs(m.line.amount), 2)}</td>
                        <td style={{ fontFamily: 'ui-monospace', fontSize: 11.5 }}>{m.receipt.receiptNo || '—'}</td>
                        <td>
                          <div style={{ fontWeight: 600, color: 'var(--brand-700)' }}>{m.receipt.invoiceNo || '—'}</div>
                          <div style={{ fontSize: 11, color: 'var(--ink-500)' }}>{m.receipt.projectName || ''}</div>
                        </td>
                        <td style={{ textAlign: 'center' }}>
                          <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--good)', background: 'var(--good-bg)', borderRadius: 10, padding: '1px 7px' }}>{m.via === 'gross' ? 'เต็ม' : 'สุทธิ'}</span>
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
        )}
        {/* ── เงินเข้าจริง แต่ยังไม่มีใบรับ (เงินเข้าอื่น / ยังไม่บันทึก) ── */}
        {tab === 'depositNoReceipt' && (
          depositNoReceipt.length === 0
            ? <BREmpty text="✓ เงินเข้าจริงทุกรายการมีใบรับเงินรองรับแล้ว" />
            : <div style={{ maxHeight: '52vh', overflow: 'auto' }}>
                <div style={{ fontSize: 12, color: 'var(--ink-600)', marginBottom: 8 }}>
                  เงินเข้าธนาคารจริง แต่<b>ยังไม่มีใบรับเงิน</b>ในระบบ — อาจเป็นเงินเข้าอื่น (ดอกเบี้ย / เงินกู้ / โอนเข้า / คืนเงิน) หรือ IV ที่ยังไม่บันทึกรับ
                </div>
                <table className="tbl" style={{ width: '100%', fontSize: 12.5 }}>
                  <thead style={{ position: 'sticky', top: 0, background: 'var(--surface)', zIndex: 1 }}>
                    <tr><th style={{ width: 96 }}>วันที่</th><th>รายละเอียด</th><th style={{ width: 110 }}>อ้างอิง</th><th style={{ width: 130, textAlign: 'right' }}>เงินเข้าจริง</th></tr>
                  </thead>
                  <tbody>
                    {depositNoReceipt.map((l, i) => (
                      <tr key={l.id || i}>
                        <td style={{ whiteSpace: 'nowrap', color: 'var(--ink-600)' }}>{fmtDate(l.date) || l.date}</td>
                        <td>{l.desc || '—'}</td>
                        <td style={{ fontFamily: 'ui-monospace', fontSize: 11.5, color: 'var(--brand-700)' }}>{l.ref || '—'}</td>
                        <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: 'oklch(50% 0.13 250)' }}>{fmtNum(Math.abs(l.amount), 2)}</td>
                      </tr>
                    ))}
                  </tbody>
                  <tfoot><tr style={{ borderTop: '2px solid var(--line)' }}>
                    <td colSpan={3} style={{ textAlign: 'right', fontWeight: 700, color: 'var(--ink-600)', padding: '6px 8px' }}>รวม {depositNoReceipt.length} รายการ</td>
                    <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 800, color: 'oklch(50% 0.13 250)' }}>{fmtNum(stats.depositNoReceiptAmt, 2)}</td>
                  </tr></tfoot>
                </table>
              </div>
        )}
      </div>
    </div>
  );
}

// ─── เซลล์จำนวนเงิน (เขียว=เข้า แดง=ออก) ─────────────────────────────────────────
function brAmtCell(v) {
  const n = Number(v) || 0;
  return <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: n >= 0 ? 'var(--good)' : 'var(--bad)' }}>
    {n >= 0 ? '' : '−'}{fmtNum(Math.abs(n), 2)}
  </span>;
}
// สถิติกระทบ Mango ของบัญชี+เดือน (ใช้ทั้ง overview + detail) — suggestion คำนวณสดจาก match ที่ยืนยันแล้ว
function brMangoStat(accNo, ym, bookAll, linesAll, matches) {
  const moves = (((bookAll[accNo] || {})[ym]) || {}).moves || [];
  const stm = ((linesAll[accNo] || {})[ym]) || [];
  const myM = (matches || []).filter(m => m.accountNo === accNo && m.ym === ym && m.status !== 'orphaned');
  const usedBook = new Set(), usedStm = new Set();
  myM.forEach(m => { (m.bookIds || []).forEach(id => usedBook.add(id)); (m.stmIds || []).forEach(id => usedStm.add(id)); });
  const freeMoves = moves.filter(m => !usedBook.has(m.id));
  const freeStm = stm.filter(l => !usedStm.has(l.id));
  const sg = brReconcileMango({ movements: freeMoves, stmLines: freeStm });
  const totalStm = stm.length;
  return { moves: moves, stm: stm, freeMoves: freeMoves, freeStm: freeStm, sg: sg, confirmed: myM,
    usedStm: usedStm, totalStm: totalStm, pct: totalStm ? Math.round(usedStm.size / totalStm * 100) : 0,
    suggested: sg.matched.length, unmatched: sg.bookOnly.length + sg.stmOnly.length, hasData: moves.length > 0 || totalStm > 0 };
}
function brPctColor(pct, hasData) {
  if (!hasData) return 'var(--ink-300)';
  return pct >= 90 ? 'var(--good)' : pct >= 60 ? 'var(--warn)' : 'var(--bad)';
}

// ─── หน้าย่อย: เทียบงบกระทบยอด Mango ERP ↔ STM (overview ⇄ detail · จับคู่ + ยืนยัน + M-to-N) ──
function BRMangoTab(props) {
  const { accounts, accountNo, setAccountNo, acct, month, stmLines, linesAll, bookAll, matches, readOnly, toast,
          onImportBook, onImportStatement, onConfirm, onManualLink, onUnmatch, onUnmatchMany } = props;
  const [view, setView] = brState('overview');   // 'overview' (การ์ดทุกบัญชี) | 'detail' (บัญชีที่เลือก)

  // ── overview: สถิติกระทบรายบัญชีของเดือนที่เลือก ──
  const ov = brMemo(() => accounts.map(a => Object.assign({ a: a, accNo: a.accountNo },
    brMangoStat(a.accountNo, month, bookAll, linesAll, matches))), [accounts, bookAll, linesAll, matches, month]);
  const withData = ov.filter(o => o.hasData);
  const kAvgPct = withData.length ? Math.round(withData.reduce((s, o) => s + o.pct, 0) / withData.length) : 0;
  const kPending = ov.reduce((s, o) => s + o.suggested, 0);
  const kUnmatched = ov.reduce((s, o) => s + o.unmatched, 0);

  if (view === 'detail') {
    const book = ((bookAll[accountNo] || {})[month]) || { meta: null, moves: [], outs: [] };
    const myMatches = matches.filter(m => m.accountNo === accountNo && m.ym === month);
    return <BRMangoDetail key={accountNo + month} acct={acct} month={month} stmLines={stmLines}
      accounts={accounts}
      book={book} matches={myMatches} readOnly={readOnly} toast={toast}
      onImportBook={onImportBook} onImportStatement={onImportStatement}
      onConfirm={onConfirm} onManualLink={onManualLink} onUnmatch={onUnmatch} onUnmatchMany={onUnmatchMany}
      onBack={() => setView('overview')} />;
  }

  // ── OVERVIEW ──
  return (
    <div>

      {/* KPI strip (compact) */}
      <div className="grid" style={{ gridTemplateColumns: 'repeat(4, minmax(0,1fr))', gap: 10, marginBottom: 14 }}>
        {[
          { label: 'บัญชีทั้งหมด',  value: accounts.length, unit: '',  col: 'var(--brand-600)' },
          { label: '% กระทบเฉลี่ย', value: kAvgPct,         unit: '%', col: brPctColor(kAvgPct, withData.length > 0) },
          { label: 'รอยืนยัน',      value: kPending,        unit: '',  col: 'var(--warn)' },
          { label: 'ค้างกระทบ',     value: kUnmatched,      unit: '',  col: 'var(--bad)' },
        ].map((k, i) => (
          <div key={i} className="card" style={{ padding: '8px 12px', borderLeft: '3px solid ' + k.col }}>
            <div style={{ fontSize: 11, color: 'var(--ink-500)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{k.label}</div>
            <div style={{ fontSize: 20, fontWeight: 800, color: 'var(--ink-800)', fontVariantNumeric: 'tabular-nums', lineHeight: 1.15, marginTop: 1 }}>
              {fmtNum(k.value, 0)}{k.unit && <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-500)', marginLeft: 2 }}>{k.unit}</span>}
            </div>
          </div>
        ))}
      </div>

      {/* account cards (compact) */}
      <div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 10 }}>
        {ov.map(o => {
          const b = bdBrand(brBrandKey(o.a));
          const col = brPctColor(o.pct, o.hasData);
          // โชว์ชื่อบัญชีเฉพาะเมื่อไม่ซ้ำกับชื่อย่อแบงค์ (กันบรรทัด "SCB" ซ้ำ)
          const acctName = (o.a.accountName || '').trim();
          const showName = acctName && acctName.toLowerCase() !== String(b.label).trim().toLowerCase();
          return (
            <button key={o.accNo} type="button" onClick={() => { setAccountNo(o.accNo); setView('detail'); }}
              className="card" style={{ textAlign: 'left', cursor: 'pointer', padding: '9px 11px', border: '1px solid var(--line)',
                borderTop: '3px solid ' + col, display: 'flex', flexDirection: 'column', gap: 5, fontFamily: 'inherit' }}>
              {/* หัวการ์ด: โลโก้ + ชื่อบัญชี (ซ้าย) · % (ขวาบน) */}
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
                  <div style={{ transform: 'scale(.82)', transformOrigin: 'left center', flex: 'none' }}><HpBankLogo name={o.a.bankName} /></div>
                  <div style={{ minWidth: 0 }}>
                    <div style={{ fontWeight: 700, fontSize: 12.5, color: 'var(--ink-800)' }}>{b.label} <span style={{ opacity: .6, fontWeight: 500 }}>···{bdLast4(o.accNo)}</span></div>
                    {showName && <div style={{ fontSize: 10, color: 'var(--ink-500)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{acctName}</div>}
                  </div>
                </div>
                {o.hasData && <span style={{ fontSize: 21, fontWeight: 800, color: col, fontVariantNumeric: 'tabular-nums', flex: 'none', lineHeight: 1 }}>{o.pct}%</span>}
              </div>
              {o.hasData ? (
                <div>
                  <div style={{ fontSize: 10.5, color: 'var(--ink-500)', marginBottom: 3 }}>กระทบ {o.usedStm.size}/{o.totalStm} รายการ</div>
                  <div style={{ height: 5, background: 'var(--ink-100)', borderRadius: 4, overflow: 'hidden', margin: '0 0 6px' }}>
                    <div style={{ height: '100%', width: Math.min(100, o.pct) + '%', background: col }} />
                  </div>
                  <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', fontSize: 10.5 }}>
                    <span style={{ color: 'var(--good)', fontWeight: 600 }}>✓ {o.confirmed.length}</span>
                    <span style={{ color: 'var(--warn)', fontWeight: 600 }}>⏳ {o.suggested}</span>
                    <span style={{ color: 'var(--bad)', fontWeight: 600 }}>⚠ ค้าง {o.unmatched}</span>
                  </div>
                </div>
              ) : (
                <div style={{ fontSize: 11, color: 'var(--ink-400)', padding: '4px 0' }}>ยังไม่มีข้อมูล — กดเพื่อนำเข้า</div>
              )}
              <div style={{ fontSize: 10.5, color: 'var(--brand-600)', fontWeight: 600, marginTop: 'auto' }}>เปิดดูรายละเอียด →</div>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ─── แถวกระทบแบบ side-by-side (เลียนแบบ PEAK/Express): MANGO ฝั่งซ้าย · BANK ฝั่งขวา ───────────
//    ฝั่งหนึ่งของแถว — หลายบรรทัด หรือ placeholder ลายทาง "— ไม่พบใน … —"
function BRSide({ rows, emptyLabel }) {
  if (!rows || !rows.length) return (
    <div style={{ background: 'repeating-linear-gradient(45deg, var(--ink-50), var(--ink-50) 7px, var(--ink-100) 7px, var(--ink-100) 14px)',
      borderRadius: 6, padding: '7px 10px', color: 'var(--ink-400)', fontSize: 11.5, fontStyle: 'italic' }}>{emptyLabel}</div>
  );
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
      {rows.map((r, i) => {
        const n = Number(r.amount) || 0;
        return (
          <div key={i} style={{ display: 'flex', alignItems: 'baseline', gap: 7 }}>
            <span style={{ color: 'var(--ink-500)', whiteSpace: 'nowrap', fontSize: 11 }}>{fmtDate(r.date) || r.date || '—'}</span>
            {r.ref && <span style={{ fontFamily: 'ui-monospace', fontSize: 10, background: 'var(--ink-100)', color: 'var(--ink-700)', borderRadius: 4, padding: '0 5px', whiteSpace: 'nowrap', maxWidth: 130, overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.ref}</span>}
            <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }} title={r.desc}>{r.desc || '—'}</span>
            <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, whiteSpace: 'nowrap', fontSize: 12, color: n >= 0 ? 'var(--good)' : 'var(--bad)' }}>{n >= 0 ? '+' : '−'}{fmtNum(Math.abs(n), 2)}</span>
          </div>
        );
      })}
    </div>
  );
}
const BR_STATUS = {
  exact:     { t: 'EXACT',      c: 'var(--good)' },
  suggested: { t: 'รอยืนยัน',    c: 'var(--warn)' },
  ambiguous: { t: '⚠ ตรวจก่อน',  c: 'var(--bad)' },
  confirmed: { t: 'ยืนยันแล้ว',   c: 'var(--good)' },
  manual:    { t: 'จับคู่เอง',    c: 'var(--brand-600)' },
  orphaned:  { t: 'ต้องตรวจ',     c: 'var(--bad)' },
  bankOnly:  { t: 'ค้าง Bank',    c: 'var(--bad)' },
  bookOnly:  { t: 'ค้าง Mango',   c: 'var(--warn)' },
};
function BRStatusBadge({ kind, sub }) {
  const m = BR_STATUS[kind] || { t: kind, c: 'var(--ink-500)' };
  return (
    <div style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1 }}>
      <span style={{ fontSize: 11, fontWeight: 700, color: m.c, background: 'color-mix(in oklch, ' + m.c + ' 14%, #fff)', borderRadius: 6, padding: '2px 8px', whiteSpace: 'nowrap' }}>{m.t}</span>
      {sub && <span style={{ fontSize: 9.5, color: 'var(--ink-400)' }}>{sub}</span>}
    </div>
  );
}
// ── ตัวเลือก "ผู้รับผิดชอบ" ราย item ที่ค้างกระทบ → เก็บ synced ใน manualOverrides
//    คีย์ brAssign.<itemId> = username (ฝ่ายการเงิน) · ทีมเห็นร่วม → คนทำมากดดูของตัวเองได้
function BRAssignSelect({ itemId, financeUsers, readOnly }) {
  const cur = (window.WTPOverride && WTPOverride.get('brAssign.' + itemId)) || '';
  const known = financeUsers.find(f => f.u === cur);
  const nameOf = (uname) => { const f = financeUsers.find(x => x.u === uname); return f ? (f.n || f.u) : uname; };
  if (readOnly) {
    return <span style={{ fontSize: 11.5, color: cur ? 'var(--ink-700)' : 'var(--ink-400)', fontWeight: cur ? 600 : 400 }}>
      {cur ? '👤 ' + nameOf(cur) : '—'}</span>;
  }
  return (
    <select value={cur} onChange={e => WTPOverride.setRaw('brAssign.' + itemId, e.target.value || null)} title="มอบหมายผู้รับผิดชอบบันทึกรายการนี้"
      style={{ width: '100%', maxWidth: 150, padding: '4px 6px', fontSize: 11.5, fontFamily: 'inherit', borderRadius: 7,
        border: '1px solid ' + (cur ? 'var(--brand-400)' : 'var(--line)'), background: cur ? 'color-mix(in oklch, var(--brand-500) 8%, #fff)' : '#fff',
        color: cur ? 'var(--brand-700)' : 'var(--ink-500)', fontWeight: cur ? 600 : 400 }}>
      <option value="">— ไม่ระบุ —</option>
      {financeUsers.map(f => <option key={f.u} value={f.u}>{f.n || f.u}</option>)}
      {cur && !known && <option value={cur}>{cur} (นอกฝ่ายการเงิน)</option>}
    </select>
  );
}

// สีพื้นหัวตาราง — ต้องตั้งที่ <th> ราย cell (border-collapse + sticky ทำให้ bg บน <thead>/<tr> ไม่วาด)
//   ใช้ srgb + white (ไม่ใช่ oklch/var(--surface)) เพราะ var(--surface)=โปร่งใส & บางเบราว์เซอร์ไม่รองรับ
//   color-mix แบบ oklch → ออกมาโปร่งใส (หัวตารางเลยไม่มีสี). srgb+white = ฟ้าอ่อนทึบ เห็นชัดทุกเบราว์เซอร์.
const BR_TH_BG = 'color-mix(in srgb, var(--brand-500) 12%, white)';
// ตารางแถวกระทบ side-by-side (MANGO | BANK STATEMENT | สถานะ | [ผู้รับผิดชอบ] | จัดการ) — item: {key,mango[],bank[],statusKind,statusSub,action,assignee,checkId,checkSide,rowBg}
function BRReconTable({ items, selectable, sel, onToggle, emptyText, showAssignee, maxH }) {
  if (!items.length) return <BREmpty text={emptyText || 'ไม่มีรายการ'} />;
  return (
    <div style={{ maxHeight: maxH || '54vh', overflow: 'auto', border: '1px solid var(--line)', borderRadius: 10 }}>
      <table style={{ width: '100%', fontSize: 12, tableLayout: 'fixed', borderCollapse: 'collapse' }}>
        <thead style={{ position: 'sticky', top: 0, zIndex: 1 }}>
          <tr style={{ fontSize: 10.5, color: 'var(--ink-600)', letterSpacing: .3 }}>
            {selectable && <th style={{ width: 32, background: BR_TH_BG, borderBottom: '1px solid var(--line)' }}></th>}
            <th style={{ textAlign: 'left', padding: '8px 10px', fontWeight: 700, background: BR_TH_BG, borderBottom: '1px solid var(--line)' }}>MANGO (สมุดบัญชี)</th>
            <th style={{ textAlign: 'left', padding: '8px 10px', borderLeft: '1px solid var(--ink-100)', borderBottom: '1px solid var(--line)', fontWeight: 700, background: BR_TH_BG }}>BANK STATEMENT</th>
            <th style={{ width: 118, textAlign: 'left', padding: '8px 8px', borderLeft: '1px solid var(--ink-100)', borderBottom: '1px solid var(--line)', fontWeight: 700, background: BR_TH_BG }}>สถานะ</th>
            {showAssignee && <th style={{ width: 120, textAlign: 'left', padding: '8px 8px', borderLeft: '1px solid var(--ink-100)', borderBottom: '1px solid var(--line)', fontWeight: 700, background: BR_TH_BG }}>ผู้รับผิดชอบ</th>}
            <th style={{ width: 86, textAlign: 'center', borderLeft: '1px solid var(--ink-100)', borderBottom: '1px solid var(--line)', fontWeight: 700, background: BR_TH_BG }}>จัดการ</th>
          </tr>
        </thead>
        <tbody>
          {items.map(it => (
            <tr key={it.key} style={Object.assign({ borderTop: '1px solid var(--ink-100)' }, it.rowBg ? { background: it.rowBg } : null)}>
              {selectable && <td style={{ textAlign: 'center', verticalAlign: 'middle' }}>{it.checkId != null ? <input type="checkbox" checked={!!(sel && sel[it.checkId])} onChange={() => onToggle(it.checkId, it.checkSide)} /> : null}</td>}
              <td style={{ verticalAlign: 'middle', padding: '7px 10px' }}><BRSide rows={it.mango} emptyLabel="— ไม่พบใน Mango —" /></td>
              <td style={{ verticalAlign: 'middle', padding: '7px 10px', borderLeft: '1px solid var(--ink-100)' }}><BRSide rows={it.bank} emptyLabel="— ไม่พบใน Bank —" /></td>
              <td style={{ verticalAlign: 'middle', padding: '7px 8px', borderLeft: '1px solid var(--ink-100)' }}><BRStatusBadge kind={it.statusKind} sub={it.statusSub} /></td>
              {showAssignee && <td style={{ verticalAlign: 'middle', padding: '7px 8px', borderLeft: '1px solid var(--ink-100)' }}>{it.assignee || <span style={{ color: 'var(--ink-300)' }}>—</span>}</td>}
              <td style={{ textAlign: 'center', verticalAlign: 'middle', borderLeft: '1px solid var(--ink-100)' }}>{it.action}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

// ─── Modal: เตือนตอนนำเข้า เมื่อไฟล์เป็นของคนละบัญชีกับที่เลือก (กันวางผิดธนาคาร) ──
function BRImportTargetModal({ fileAcct, selAccNo, month, onPick, onCancel }) {
  const fileLabel = bdBrand(brBrandKey(fileAcct)).label + ' ···' + bdLast4(fileAcct.accountNo);
  return (
    <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.4)', display: 'grid', placeItems: 'center', zIndex: 1000 }} onClick={onCancel}>
      <div className="card" style={{ width: 'min(460px,92vw)', padding: 20 }} onClick={e => e.stopPropagation()}>
        <div style={{ fontWeight: 800, fontSize: 16, marginBottom: 8, color: 'var(--warn)' }}>⚠️ ไฟล์นี้อาจนำเข้าผิดบัญชี</div>
        <div style={{ fontSize: 13, color: 'var(--ink-700)', lineHeight: 1.7, marginBottom: 16 }}>
          ไฟล์งบ <b>{brFmtMonth(month)}</b> นี้เป็นของบัญชี <b style={{ color: 'var(--brand-700)' }}>{fileLabel}</b><br />
          แต่ตอนนี้คุณเลือกบัญชี <b>···{bdLast4(selAccNo)}</b> อยู่ — ต้องการนำเข้าที่บัญชีไหน?
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          <button className="btn btn-primary" onClick={() => onPick(fileAcct.accountNo)}>✓ นำเข้าที่ {fileLabel} (ตามไฟล์ — แนะนำ)</button>
          <button className="btn btn-ghost" onClick={() => onPick(null)}>นำเข้าที่บัญชี ···{bdLast4(selAccNo)} (ที่เลือกไว้)</button>
          <button className="btn btn-ghost" onClick={onCancel} style={{ color: 'var(--ink-500)' }}>ยกเลิก</button>
        </div>
      </div>
    </div>
  );
}

// ─── Detail — กระทบบัญชี+เดือนเดียว: นำเข้า → KPI → tabs (รอยืนยัน/รอกระทบ/กระทบแล้ว/ทั้งหมด/Outstanding) ──
function BRMangoDetail({ acct, month, stmLines, accounts, book, matches, readOnly, toast,
                         onImportBook, onImportStatement, onConfirm, onManualLink, onUnmatch, onUnmatchMany, onBack }) {
  const [busy, setBusy] = brState(false);
  const [mismatch, setMismatch] = brState(null);         // { parsed, fileAcct } — modal เตือนไฟล์คนละบัญชี
  const [tab, setTab] = brState('pending');   // pending | unmatched | matched | all | outstanding
  const [selB, setSelB] = brState({});         // เลือก bookId (ฝั่งเกิน) เพื่อจับคู่เอง
  const [selS, setSelS] = brState({});         // เลือก stmId (ฝั่งขาด)
  const [selM, setSelM] = brState({});         // เลือก matchId (แท็บ "กระทบแล้ว") เพื่อยกเลิกทีละหลายรายการ
  const [q, setQ] = brState('');
  const [dir, setDir] = brState('all');        // ตัวกรองเงินเข้า/ออก (เฉพาะ unmatched/all): all | in | out
  const [sort, setSort] = brState('date_desc');// date_desc | date_asc | amt_desc | amt_asc
  const [dFrom, setDFrom] = brState('');
  const [dTo, setDTo] = brState('');
  const [aMin, setAMin] = brState('');
  const [aMax, setAMax] = brState('');
  const [fullscreen, setFullscreen] = brState(false);  // ขยายตารางทำงานเต็มหน้าเว็บ — ซ่อนหัว/KPI ด้านบน
  const fileRef = brRef(null);

  brEffect(() => {
    if (!fullscreen) return;
    const onKey = (e) => { if (e.key === 'Escape') setFullscreen(false); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [fullscreen]);

  const moves = (book && book.moves) || [];
  const outs  = (book && book.outs) || [];
  const meta  = (book && book.meta) || null;
  const accNo = acct ? acct.accountNo : '';

  // ── ผู้รับผิดชอบ (ฝ่ายการเงิน) — รายชื่อจาก user directory (synced) + ผู้ใช้ปัจจุบัน + ตัวกรอง ──
  const [assignFilter, setAssignFilter] = brState('all');   // 'all' | 'mine' | 'none' | <username>
  const financeUsers = (window.wtpUsersByDept ? wtpUsersByDept('finance') : [])
    .slice().sort((a, b) => String(a.n || a.u).localeCompare(String(b.n || b.u), 'th'));
  const meUser = (() => { try { return (JSON.parse(localStorage.getItem('wtp-session') || 'null') || {}).username || ''; } catch (_) { return ''; } })();
  const assigneeOf = (id) => (window.WTPOverride && WTPOverride.get('brAssign.' + id)) || '';
  const assigneeName = (uname) => { const f = financeUsers.find(x => x.u === uname); return f ? (f.n || f.u) : uname; };

  const st = brMemo(() => brMangoStat(accNo, month, { [accNo]: { [month]: book } }, { [accNo]: { [month]: stmLines || [] } }, matches),
    [accNo, month, book, stmLines, matches]);
  const suggestions = brMemo(() => st.sg.matched.map(s => Object.assign({}, s, {
    confidence: s.score >= 100 ? 'exact' : (s.ambiguous ? 'ambiguous' : 'suggested'),
  })), [st]);
  const ambigCount = brMemo(() => suggestions.filter(s => s.confidence === 'ambiguous').length, [suggestions]);
  const confidentSugs = brMemo(() => suggestions.filter(s => s.confidence !== 'ambiguous'), [suggestions]);
  const orphans = matches.filter(m => m.status === 'orphaned');

  // lookup สำหรับ resolve match ที่ยืนยันแล้ว → แสดงในแท็บ "กระทบแล้ว"
  const bookById = brMemo(() => { const o = {}; moves.forEach(m => o[m.id] = m); return o; }, [moves]);
  const stmById  = brMemo(() => { const o = {}; (stmLines || []).forEach(l => o[l.id] = l); return o; }, [stmLines]);

  const onFile = (file) => {
    if (!file) return;
    setBusy(true);
    brParseFile(file).then(({ sheetNames, sheets }) => {
      let best = null;
      for (let i = 0; i < sheetNames.length; i++) {
        const p = brParseMango(sheets[sheetNames[i]] || [], file.name);
        if (p.ok && p.movements.length) { best = p; break; }
        if (p.ok && !best) best = p;
        if (!best) best = p;
      }
      setBusy(false);
      if (!best || !best.ok) { if (toast) toast('อ่านไฟล์ Mango ไม่ได้: ' + ((best && best.error) || '')); return; }
      // ── กันพลาด: หาว่าไฟล์นี้ "ควรเป็น" บัญชีไหนที่รู้จัก (จากเลขในเนื้อไฟล์ หรือชื่อไฟล์) ──
      let fileAcct = null;
      if (best.account) fileAcct = (accounts || []).find(a => bdAcctMatchesCheck(a.accountNo, best.account)) || null;
      if (!fileAcct) {
        const runs = String(file.name || '').match(/\d{4,}/g) || [];
        for (let i = 0; i < runs.length && !fileAcct; i++) {
          const run = runs[i];
          fileAcct = (accounts || []).find(a => { const ad = bdDigits(a.accountNo); return ad.length >= 4 && (ad.indexOf(run) >= 0 || run.indexOf(ad) >= 0 || ad.slice(-4) === run.slice(-4)); }) || null;
        }
      }
      // รู้แน่ว่าเป็นคนละบัญชีที่รู้จัก → เปิด modal ให้เลือก (กดผ่านมั่วไม่ได้ ต้องเลือกบัญชี)
      if (fileAcct && String(fileAcct.accountNo) !== String(accNo)) { setMismatch({ parsed: best, fileAcct: fileAcct }); return; }
      // อ่านเลขบัญชีจากไฟล์ได้แต่ไม่ตรงบัญชีที่เลือก และไม่รู้ว่าเป็นบัญชีไหน → เตือนแบบเดิม
      if (best.account && bdDigits(accNo).length >= 4 && !bdAcctMatchesCheck(accNo, best.account)) {
        if (!window.confirm('ไฟล์นี้เป็นบัญชี ' + best.account + ' แต่กำลังนำเข้าให้บัญชี ' + bdLast4(accNo) + ' เดือน ' + brFmtMonth(month) + '\nยืนยันนำเข้า?')) return;
      }
      onImportBook(best);
    }).catch(e => { setBusy(false); if (toast) toast('อ่านไฟล์ไม่ได้: ' + (e.message || e)); });
  };

  const stm = stmLines || [];

  // ── ตัวกรอง/เรียง (ทุก item มี _iso/_amt/_text) ──
  const passFilter = (it) => {
    if (q && (it._text || '').toLowerCase().indexOf(q.toLowerCase()) < 0) return false;
    if (dFrom && it._iso && it._iso < dFrom) return false;
    if (dTo && it._iso && it._iso > dTo) return false;
    const a = Math.abs(it._amt || 0);
    if (aMin !== '' && a < Number(aMin)) return false;
    if (aMax !== '' && a > Number(aMax)) return false;
    if (tab === 'unmatched' || tab === 'all') {
      if (dir === 'in' && (it._amt || 0) < 0) return false;
      if (dir === 'out' && (it._amt || 0) > 0) return false;
    }
    if (tab === 'unmatched' && assignFilter !== 'all') {
      const a = it._assignee || '';
      if (assignFilter === 'mine') { if (a !== meUser) return false; }
      else if (assignFilter === 'none') { if (a) return false; }
      else if (a !== assignFilter) return false;
    }
    return true;
  };
  const sortItems = (arr) => arr.slice().sort((x, y) => {
    if (sort === 'amt_desc') return Math.abs(y._amt || 0) - Math.abs(x._amt || 0);
    if (sort === 'amt_asc')  return Math.abs(x._amt || 0) - Math.abs(y._amt || 0);
    const c = (x._iso || '') < (y._iso || '') ? -1 : (x._iso || '') > (y._iso || '') ? 1 : 0;
    return sort === 'date_asc' ? c : -c;
  });
  const moveSide = (m) => ({ date: m.bkDate, ref: m.vno, desc: m.vendor || m.remark, amount: m.amount });
  const stmSide  = (l) => ({ date: l.date, ref: l.ref, desc: l.desc, amount: l.amount });
  const actBtn = (label, col, fn) => <button onClick={fn} style={{ fontSize: 11.5, padding: '4px 9px', borderRadius: 7, cursor: 'pointer', fontFamily: 'inherit', border: '1px solid ' + col, background: '#fff', color: col, fontWeight: 700 }}>{label}</button>;

  // ── build items ราย bucket → BRReconTable ──
  const BR_VIA_LABEL = { chq: 'เลขเช็ค+ยอด', pv: 'เลขที่ PV+ยอด', vendor: 'ชื่อผู้รับ+ยอด', amt: 'วัน+ยอด' };
  const pendingItems = suggestions.map((s, i) => ({
    key: 'p' + (s.mango.id || i), mango: [moveSide(s.mango)], bank: [stmSide(s.stm)],
    statusKind: s.confidence,
    statusSub: s.confidence === 'ambiguous' ? 'ยอดซ้ำ—ตรวจผู้รับก่อน' : (BR_VIA_LABEL[s.via] || 'วัน+ยอด'),
    _iso: s.mango.bkDate || s.stm.date, _amt: s.mango.amount, _text: [s.mango.vendor, s.mango.vno, s.stm.desc, s.stm.ref, s.mango.amount].join(' '),
    action: readOnly ? null : actBtn('ยืนยัน', 'var(--good)', () => onConfirm([s], accNo, month)),
  }));
  const unmatchedItems = st.sg.bookOnly.map((m, i) => ({
    key: 'b' + (m.id || i), mango: [moveSide(m)], bank: null, statusKind: 'bookOnly', statusSub: 'Mango ลง · ธนาคารยังไม่มี',
    checkId: m.id, checkSide: 'book', rowBg: selB[m.id] ? 'color-mix(in oklch, var(--brand-500) 8%, transparent)' : null,
    _iso: m.bkDate, _amt: m.amount, _text: [m.vendor, m.vno, m.remark, m.amount].join(' '),
    _assignee: assigneeOf(m.id), _assigneeName: assigneeName(assigneeOf(m.id)),
    assignee: <BRAssignSelect itemId={m.id} financeUsers={financeUsers} readOnly={readOnly} />,
    action: readOnly ? null : <button onClick={() => setSelB(s => Object.assign({}, s, { [m.id]: !s[m.id] }))}
      style={{ fontSize: 11.5, padding: '4px 10px', borderRadius: 7, cursor: 'pointer', fontFamily: 'inherit', fontWeight: 700, whiteSpace: 'nowrap',
        border: '1px solid ' + (selB[m.id] ? 'var(--brand-600)' : 'var(--line)'), background: selB[m.id] ? 'var(--brand-600)' : '#fff', color: selB[m.id] ? '#fff' : 'var(--brand-600)' }}>
      {selB[m.id] ? '✓ เลือกแล้ว' : '🔗 เลือก'}</button>,
  })).concat(st.sg.stmOnly.map((l, i) => ({
    key: 's' + (l.id || i), mango: null, bank: [stmSide(l)], statusKind: 'bankOnly', statusSub: 'ธนาคารหัก · ยังไม่ลงบัญชี',
    checkId: l.id, checkSide: 'stm', rowBg: selS[l.id] ? 'color-mix(in oklch, var(--brand-500) 8%, transparent)' : null,
    _iso: l.date, _amt: l.amount, _text: [l.desc, l.ref, l.amount].join(' '),
    _assignee: assigneeOf(l.id), _assigneeName: assigneeName(assigneeOf(l.id)),
    assignee: <BRAssignSelect itemId={l.id} financeUsers={financeUsers} readOnly={readOnly} />,
    action: readOnly ? null : <button onClick={() => setSelS(s => Object.assign({}, s, { [l.id]: !s[l.id] }))}
      style={{ fontSize: 11.5, padding: '4px 10px', borderRadius: 7, cursor: 'pointer', fontFamily: 'inherit', fontWeight: 700, whiteSpace: 'nowrap',
        border: '1px solid ' + (selS[l.id] ? 'var(--brand-600)' : 'var(--line)'), background: selS[l.id] ? 'var(--brand-600)' : '#fff', color: selS[l.id] ? '#fff' : 'var(--brand-600)' }}>
      {selS[l.id] ? '✓ เลือกแล้ว' : '🔗 เลือก'}</button>,
  })));
  const matchItems = matches.map((mt) => {
    const bRows = (mt.bookIds || []).map(id => bookById[id]).filter(Boolean);
    const sRows = (mt.stmIds || []).map(id => stmById[id]).filter(Boolean);
    const amt = sRows.reduce((s, l) => s + (Number(l.amount) || 0), 0) || bRows.reduce((s, b) => s + (Number(b.amount) || 0), 0);
    const kind = mt.status === 'manual' ? 'manual' : mt.status === 'orphaned' ? 'orphaned' : 'confirmed';
    return {
      key: 'm' + mt.id, mango: bRows.length ? bRows.map(moveSide) : null, bank: sRows.length ? sRows.map(stmSide) : null,
      statusKind: kind, statusSub: mt.groupId ? (bRows.length + ':' + sRows.length + ' กลุ่ม') : (mt.confidence === 'exact' ? 'ref+วัน+ยอด' : 'วัน+ยอด'),
      checkId: mt.id, checkSide: 'match',
      rowBg: selM[mt.id] ? 'color-mix(in oklch, var(--brand-500) 8%, transparent)' : (kind === 'orphaned' ? 'color-mix(in oklch, var(--bad) 6%, transparent)' : null),
      _iso: (bRows[0] && bRows[0].bkDate) || (sRows[0] && sRows[0].date), _amt: amt,
      _text: bRows.concat(sRows).map(r => (r.vendor || '') + ' ' + (r.desc || '') + ' ' + (r.vno || '') + ' ' + (r.ref || '')).join(' '),
      action: readOnly ? null : <button onClick={() => onUnmatch(mt.id)} style={{ fontSize: 11.5, padding: '3px 8px', borderRadius: 6, cursor: 'pointer', fontFamily: 'inherit', border: '1px solid var(--line)', background: '#fff', color: 'var(--ink-600)' }}>↩ ยกเลิก</button>,
    };
  });
  const allItems = pendingItems.concat(matchItems).concat(unmatchedItems);

  // ── จับคู่เองจากที่เลือก (M-to-N) ──
  const selBookRows = st.sg.bookOnly.filter(m => selB[m.id]);
  const selStmRows  = st.sg.stmOnly.filter(l => selS[l.id]);
  const selBSum = selBookRows.reduce((s, b) => s + (Number(b.amount) || 0), 0);
  const selSSum = selStmRows.reduce((s, l) => s + (Number(l.amount) || 0), 0);
  const selSumOK = (selBookRows.length > 0 && selStmRows.length > 0) && Math.abs(selBSum - selSSum) < 0.01;
  const doManualLink = () => { if (onManualLink(selBookRows, selStmRows, accNo, month)) { setSelB({}); setSelS({}); } };
  const toggleSel = (id, side) => { if (side === 'book') setSelB(s => Object.assign({}, s, { [id]: !s[id] })); else setSelS(s => Object.assign({}, s, { [id]: !s[id] })); };
  const selMerged = Object.assign({}, selB, selS);

  // ── เลือก match เพื่อยกเลิกทีละหลายรายการ (แท็บ "กระทบแล้ว") ──
  const toggleSelM = (id) => setSelM(s => Object.assign({}, s, { [id]: !s[id] }));
  const selMatchIds = matchItems.filter(it => selM[it.checkId]).map(it => it.checkId);   // เฉพาะที่ยังเป็น match จริง
  const doUnmatchMany = () => { onUnmatchMany(selMatchIds); setSelM({}); };

  // ── KPI ยอด MANGO / BANK / ผลต่าง + สรุปเงินเข้า-ออก (ค้าง) ──
  const mangoNet = moves.reduce((s, m) => s + (Number(m.amount) || 0), 0);
  const bankNet  = stm.reduce((s, l) => s + (Number(l.amount) || 0), 0);
  const diff = mangoNet - bankNet;
  const balanced = Math.abs(diff) < 0.01;
  const unIn  = unmatchedItems.filter(it => (it._amt || 0) > 0);
  const unOut = unmatchedItems.filter(it => (it._amt || 0) < 0);

  const noBook = moves.length === 0;
  const noStm = stm.length === 0;

  const tabs = [
    { key: 'pending',     label: 'รอยืนยัน',    n: pendingItems.length,   color: 'var(--warn)' },
    { key: 'unmatched',   label: 'รอกระทบยอด',  n: unmatchedItems.length, color: 'var(--bad)' },
    { key: 'matched',     label: 'กระทบแล้ว',   n: matchItems.length,     color: 'var(--good)' },
    { key: 'all',         label: 'ทั้งหมด',      n: allItems.length,       color: 'var(--brand-600)' },
    { key: 'outstanding', label: 'Outstanding', n: outs.length,           color: 'oklch(60% 0.13 250)' },
  ];
  const curItems = tab === 'pending' ? pendingItems : tab === 'unmatched' ? unmatchedItems : tab === 'matched' ? matchItems : tab === 'all' ? allItems : [];
  const shownItems = sortItems(curItems.filter(passFilter));
  const inpSt = { padding: '6px 8px', border: '1px solid var(--line)', borderRadius: 7, fontSize: 12, fontFamily: 'inherit' };
  const lblSt = { display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--ink-500)' };

  // ── ดาวน์โหลด Excel ของตารางที่กำลังดู (ตามแท็บ + ตัวกรอง) ──
  const brExportExcel = () => {
    const acctLabel = (acct ? bdBrand(brBrandKey(acct)).label + ' ···' + bdLast4(accNo) : '') + ' · ' + brFmtMonth(month);
    const tabLabel = (tabs.find(t => t.key === tab) || {}).label || tab;
    if (tab === 'outstanding') {
      const rows = outs.map(o => ({ vno: o.vno || '', vendor: o.vendor || '', amount: Number(o.amount) || 0 }));
      exportRowsToExcel(rows, [
        { key: 'vno',    label: 'เลขที่' },
        { key: 'vendor', label: 'ผู้รับ/รายการ' },
        { key: 'amount', label: 'จำนวน', type: 'number' },
      ], { filename: 'Mango_Outstanding_' + bdLast4(accNo) + '_' + month, sheetName: 'Outstanding',
        title: 'Outstanding Cheque · ' + acctLabel });
      return;
    }
    const joinSide = (rows, f) => (rows || []).map(f).filter(v => v !== '' && v != null).join(' | ');
    const sumSide  = (rows) => (rows || []).reduce((s, r) => s + (Number(r.amount) || 0), 0);
    const data = shownItems.map(it => {
      const stt = BR_STATUS[it.statusKind] || { t: it.statusKind };
      return {
        status: stt.t + (it.statusSub ? ' (' + it.statusSub + ')' : ''),
        assignee: it._assigneeName || '',
        mDate: joinSide(it.mango, r => fmtDate(r.date) || r.date || ''),
        mRef:  joinSide(it.mango, r => r.ref || ''),
        mDesc: joinSide(it.mango, r => r.desc || ''),
        mAmt:  it.mango ? sumSide(it.mango) : '',
        bDate: joinSide(it.bank, r => fmtDate(r.date) || r.date || ''),
        bRef:  joinSide(it.bank, r => r.ref || ''),
        bDesc: joinSide(it.bank, r => r.desc || ''),
        bAmt:  it.bank ? sumSide(it.bank) : '',
      };
    });
    exportRowsToExcel(data, [
      { key: 'status', label: 'สถานะ' },
      { key: 'assignee', label: 'ผู้รับผิดชอบ' },
      { key: 'mDate',  label: 'MANGO วันที่' },
      { key: 'mRef',   label: 'MANGO เลขที่' },
      { key: 'mDesc',  label: 'MANGO รายการ' },
      { key: 'mAmt',   label: 'MANGO จำนวน', type: 'number' },
      { key: 'bDate',  label: 'BANK วันที่' },
      { key: 'bRef',   label: 'BANK ref' },
      { key: 'bDesc',  label: 'BANK คำอธิบาย' },
      { key: 'bAmt',   label: 'BANK จำนวน', type: 'number' },
    ], {
      filename: 'Mango_' + tabLabel + '_' + bdLast4(accNo) + '_' + month,
      sheetName: tabLabel.slice(0, 31),
      title: 'กระทบยอด Mango ↔ Bank · ' + acctLabel + ' · ' + tabLabel,
    });
  };

  const tableMaxH = fullscreen ? 'calc(100vh - 210px)' : '54vh';

  return (
    <div style={fullscreen ? { position: 'fixed', inset: 0, zIndex: 1000, background: 'var(--bg, #f4f7fb)', padding: '10px 14px', overflow: 'auto' } : undefined}>
      {/* แถบหัวแบบบางตอนเต็มจอ */}
      {fullscreen && (
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10,
          padding: '6px 12px', marginBottom: 8, background: 'linear-gradient(90deg, #ebf8ff, transparent)',
          border: '1px solid #bee3f8', borderRadius: 8 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
            <strong style={{ color: '#1e4fbd', fontSize: 13, whiteSpace: 'nowrap' }}>โหมดเต็มจอ · เทียบ Mango</strong>
            <span style={{ fontSize: 11.5, color: 'var(--ink-500)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
              {acct ? bdBrand(brBrandKey(acct)).label + ' ···' + bdLast4(accNo) : ''} · {brFmtMonth(month)} · Mango {moves.length} · STM {(stmLines || []).length}
            </span>
          </div>
          <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
            {!noBook && <button className="btn btn-ghost" onClick={brExportExcel} title="ดาวน์โหลดตารางที่กำลังดู (ตามแท็บ+ตัวกรอง) เป็น Excel"
              style={{ color: 'var(--good)', borderColor: 'var(--good)' }}>⬇ Excel</button>}
            <button onClick={() => setFullscreen(false)}
              style={{ background: '#1e4fbd', color: '#fff', border: 'none', borderRadius: 6, padding: '6px 14px', cursor: 'pointer', fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}>
              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" style={{ verticalAlign: 'middle', marginRight: 4 }}>
                <polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/>
              </svg>
              ออกจากเต็มจอ
            </button>
          </div>
        </div>
      )}
      {/* header: back + นำเข้า */}
      {!fullscreen && (
      <div className="card anim-in" style={{ padding: 14, marginBottom: 14, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <button className="btn btn-ghost" onClick={onBack} title="กลับไปภาพรวมบัญชี">← ภาพรวม</button>
          <div>
            <div style={{ fontWeight: 800, fontSize: 15, color: 'var(--ink-800)' }}>{acct ? bdBrand(brBrandKey(acct)).label + ' ···' + bdLast4(accNo) : ''} · {brFmtMonth(month)}</div>
            <div style={{ fontSize: 12, color: 'var(--ink-500)', marginTop: 2 }}>Mango {moves.length} รายการ · STM {(stmLines || []).length} รายการ</div>
          </div>
        </div>
        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
          {!noBook && <button className="btn btn-ghost" onClick={() => setFullscreen(true)} title="ขยายตารางทำงานเต็มหน้าเว็บ — ซ่อนหัวสรุป/KPI ด้านบน"
            style={{ background: '#ebf8ff', color: '#1e4fbd', border: '1px solid #63b3ed' }}>
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
              <polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
            </svg>
            ขยายเต็มจอ
          </button>}
          {!noBook && <button className="btn btn-ghost" onClick={brExportExcel} title="ดาวน์โหลดตารางที่กำลังดู (ตามแท็บ+ตัวกรอง) เป็น Excel"
            style={{ color: 'var(--good)', borderColor: 'var(--good)' }}>⬇ Excel</button>}
          {!readOnly && <React.Fragment>
            {onImportStatement && <button className="btn btn-ghost" onClick={onImportStatement} title="นำเข้า statement ธนาคารของบัญชีนี้">📥 statement</button>}
            {pendingItems.length > 0 && <button className="btn btn-ghost" onClick={() => setTab('pending')} title="ดูคู่ที่ระบบจับให้อัตโนมัติ"
              style={{ color: 'var(--warn)', borderColor: 'var(--warn)' }}>⚡ จับคู่อัตโนมัติ ({pendingItems.length})</button>}
            <button className="btn btn-primary" onClick={() => fileRef.current && fileRef.current.click()}>
              {busy ? 'กำลังอ่าน…' : (noBook ? '📂 นำเข้างบกระทบยอด Mango' : '📂 นำเข้า/อัปเดต Mango')}
            </button>
            <input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" style={{ display: 'none' }}
              onChange={e => { const f = e.target.files && e.target.files[0]; if (f) onFile(f); e.target.value = ''; }} />
          </React.Fragment>}
        </div>
      </div>
      )}

      {mismatch && (
        <BRImportTargetModal fileAcct={mismatch.fileAcct} selAccNo={accNo} month={month}
          onPick={(toAccNo) => { onImportBook(mismatch.parsed, toAccNo); setMismatch(null); }}
          onCancel={() => setMismatch(null)} />
      )}

      {orphans.length > 0 && (
        <div className="card" style={{ padding: '10px 14px', marginBottom: 12, background: '#fffbeb', borderLeft: '4px solid #f6ad55', fontSize: 12.5, color: 'var(--ink-700)' }}>
          ⚠️ มี {orphans.length} การจับคู่ที่อ้างถึงรายการ Mango ซึ่งเปลี่ยน/หายไปหลังนำเข้าใหม่ — ตรวจในแท็บ "กระทบแล้ว" แล้วจับคู่ใหม่ถ้าจำเป็น
        </div>
      )}
      {noBook && (
        <div className="card" style={{ padding: 28, textAlign: 'center', color: 'var(--ink-500)' }}>
          <div style={{ fontSize: 36, marginBottom: 8 }}>📒</div>
          <div style={{ fontWeight: 600, color: 'var(--ink-700)', marginBottom: 4 }}>ยังไม่ได้นำเข้างบกระทบยอด Mango ของบัญชีนี้</div>
          <div style={{ fontSize: 12.5, marginBottom: 12 }}>ไฟล์ <b>"MM.YYYY งบกระทบยอด Mango-XXXX.xlsx"</b> (XXXX = เลขบัญชี)</div>
          {!readOnly && <button className="btn btn-primary" onClick={() => fileRef.current && fileRef.current.click()}>📂 นำเข้างบกระทบยอด Mango</button>}
          {noStm && <div style={{ fontSize: 12, marginTop: 12, color: 'var(--warn)' }}>⚠️ ยังไม่มี statement ของเดือนนี้ — กด "📥 นำเข้า statement" ด้านบนด้วย</div>}
        </div>
      )}

      {!noBook && (<div>
        {/* KPI — ยอด MANGO / ยอด BANK / ผลต่าง / กระทบแล้ว / ค้างกระทบ */}
        {!fullscreen && (
        <div className="grid" style={{ gridTemplateColumns: 'repeat(5, minmax(0,1fr))', gap: 10, marginBottom: 14 }}>
          <KpiTile label={'ยอด MANGO · ' + moves.length + ' รายการ'} value={mangoNet} digits={2} accent={mangoNet < 0 ? 'var(--bad)' : 'var(--brand-600)'} icon="bank" />
          <KpiTile label={'ยอด BANK · ' + stm.length + ' รายการ'} value={bankNet} digits={2} accent={bankNet < 0 ? 'var(--bad)' : 'var(--brand-600)'} icon="bank" />
          <KpiTile label={balanced ? 'ผลต่าง · สมดุล ✓' : 'ผลต่าง · ยังไม่สมดุล'} value={diff} digits={2} accent={balanced ? 'var(--good)' : 'var(--bad)'} icon="coin" />
          <KpiTile label="กระทบแล้ว" value={matchItems.length} digits={0} unit="" accent="var(--good)" icon="check" />
          <KpiTile label="ค้างกระทบ" value={unmatchedItems.length} digits={0} unit="" accent="var(--bad)" icon="arrow_down" />
        </div>
        )}

        <div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 16 }}>
          {/* tabs — pill-count style */}
          <div style={{ display: 'flex', gap: 2, borderBottom: '1px solid var(--line)', flexWrap: 'wrap', padding: '0 6px' }}>
            {tabs.map(t => {
              const active = tab === t.key;
              return (
                <button key={t.key} onClick={() => setTab(t.key)} style={{ border: 'none', background: 'transparent', cursor: 'pointer',
                  padding: '11px 14px', fontFamily: 'inherit', fontSize: 12.5, fontWeight: active ? 700 : 600,
                  color: active ? t.color : 'var(--ink-500)', borderBottom: active ? '2px solid ' + t.color : '2px solid transparent',
                  display: 'inline-flex', alignItems: 'center', gap: 6 }}>
                  {t.label}
                  <span style={{ fontSize: 11, fontWeight: 700, background: active ? t.color : 'var(--ink-200)', color: active ? '#fff' : 'var(--ink-600)', borderRadius: 10, padding: '0 7px', minWidth: 18, textAlign: 'center' }}>{t.n}</span>
                </button>
              );
            })}
          </div>

          <div style={{ padding: 14 }}>
            {/* filter bar */}
            {tab !== 'outstanding' && (
              <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 12 }}>
                <input value={q} onChange={e => setQ(e.target.value)} placeholder="🔍 ค้นหา ref / เลขที่ / คำอธิบาย / ยอดเงิน..."
                  style={{ flex: '1 1 220px', minWidth: 170, padding: '7px 10px', border: '1px solid var(--line)', borderRadius: 8, fontSize: 12.5, fontFamily: 'inherit' }} />
                <label style={lblSt}>📅 <input type="date" value={dFrom} onChange={e => setDFrom(e.target.value)} style={inpSt} /> – <input type="date" value={dTo} onChange={e => setDTo(e.target.value)} style={inpSt} /></label>
                <label style={lblSt}>ยอด <input type="number" value={aMin} onChange={e => setAMin(e.target.value)} placeholder="ต่ำสุด" style={Object.assign({ width: 76 }, inpSt)} /> – <input type="number" value={aMax} onChange={e => setAMax(e.target.value)} placeholder="สูงสุด" style={Object.assign({ width: 76 }, inpSt)} /></label>
                <select value={sort} onChange={e => setSort(e.target.value)} style={inpSt}>
                  <option value="date_desc">วันที่ใหม่→เก่า</option>
                  <option value="date_asc">วันที่เก่า→ใหม่</option>
                  <option value="amt_desc">ยอดมาก→น้อย</option>
                  <option value="amt_asc">ยอดน้อย→มาก</option>
                </select>
              </div>
            )}

            {/* ── รอยืนยัน — suggestions (side-by-side) ── */}
            {tab === 'pending' && (
              <div>
                {!readOnly && pendingItems.length > 0 && (
                  <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
                    {ambigCount > 0 && confidentSugs.length > 0 && (
                      <button onClick={() => onConfirm(confidentSugs, accNo, month)}
                        style={{ background: 'var(--good)', color: '#fff', border: 'none', borderRadius: 9, padding: '9px 18px', fontSize: 13.5, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit' }}>
                        ✓ ยืนยันเฉพาะที่มั่นใจ ({confidentSugs.length})
                      </button>
                    )}
                    <button onClick={() => onConfirm(suggestions, accNo, month)}
                      style={{ background: ambigCount > 0 ? '#fff' : 'var(--good)', color: ambigCount > 0 ? 'var(--good)' : '#fff', border: ambigCount > 0 ? '1px solid var(--good)' : 'none', borderRadius: 9, padding: '9px 18px', fontSize: 13.5, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit' }}>
                      ✓ ยืนยันทั้งหมด ({pendingItems.length})
                    </button>
                    <span style={{ fontSize: 12, color: 'var(--ink-500)' }}>🤖 ระบบจับคู่ MANGO ↔ BANK ด้วยยอด+วัน+เลขเช็ค+เลขที่ PV+ชื่อผู้รับ — ตรวจแล้วกดยืนยัน</span>
                  </div>
                )}
                {ambigCount > 0 && (
                  <div className="card" style={{ padding: '9px 13px', marginBottom: 10, background: 'color-mix(in oklch, var(--bad) 7%, #fff)', borderLeft: '4px solid var(--bad)', fontSize: 12.5, color: 'var(--ink-700)' }}>
                    ⚠️ มี <b>{ambigCount}</b> คู่ที่ระบบเดาจาก <b>ยอด+วัน</b> เท่านั้น (ยอดซ้ำกันหลายรายการ ไม่มีเลขเช็ค/PV/ชื่อผู้รับให้ยืนยัน) — <b>อาจสลับผู้รับ</b> ตรวจก่อนกดยืนยันทีละรายการ อย่าเพิ่งเหมา "ยืนยันทั้งหมด"
                  </div>
                )}
                <BRReconTable items={shownItems} maxH={tableMaxH} emptyText={st.freeMoves.length === 0 && st.freeStm.length === 0 ? '✓ จับคู่ครบแล้ว — ไม่มีรายการรอยืนยัน' : 'ไม่มีคู่อัตโนมัติ — ใช้แท็บ "รอกระทบยอด" จับคู่เอง'} />
              </div>
            )}

            {/* ── รอกระทบยอด — เลือกจับคู่เอง (M-to-N) ── */}
            {tab === 'unmatched' && (
              <div>
                {!readOnly && (
                  <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10, flexWrap: 'wrap', padding: '8px 12px', background: 'var(--ink-50)', borderRadius: 8 }}>
                    <span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ink-700)' }}>เลือก {selBookRows.length + selStmRows.length} รายการ · ผลรวมต้องตรงเป๊ะ ±0.01</span>
                    <button disabled={!selSumOK} onClick={doManualLink}
                      style={{ background: selSumOK ? 'var(--brand-600)' : 'var(--ink-200)', color: '#fff', border: 'none', borderRadius: 9, padding: '8px 16px', fontSize: 13, fontWeight: 700, cursor: selSumOK ? 'pointer' : 'not-allowed', fontFamily: 'inherit' }}>
                      🔗 จับคู่ที่เลือก ({selBookRows.length}:{selStmRows.length})
                    </button>
                    {(selBookRows.length > 0 || selStmRows.length > 0) && (
                      <span style={{ fontSize: 12, color: selSumOK ? 'var(--good)' : 'var(--bad)', fontWeight: 600 }}>
                        MANGO {fmtNum(selBSum, 2)} {selSumOK ? '=' : '≠'} BANK {fmtNum(selSSum, 2)} {selSumOK ? '✓' : '(ผลรวมต้องตรง)'}
                      </span>
                    )}
                  </div>
                )}
                {/* summary + in/out filter */}
                <div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', marginBottom: 10, fontSize: 12 }}>
                  {meta && meta.opening != null && <span style={{ color: 'var(--ink-600)' }}>ยอดยกมา <b style={{ fontVariantNumeric: 'tabular-nums' }}>{fmtNum(meta.opening, 2)}</b></span>}
                  <span style={{ color: 'var(--good)' }}>เงินเข้า {unIn.length} รายการ · {fmtNum(unIn.reduce((s, it) => s + (it._amt || 0), 0), 2)}</span>
                  <span style={{ color: 'var(--bad)' }}>เงินออก {unOut.length} รายการ · {fmtNum(Math.abs(unOut.reduce((s, it) => s + (it._amt || 0), 0)), 2)}</span>
                  <span style={{ flex: 1 }} />
                  <div style={{ display: 'inline-flex', border: '1px solid var(--line)', borderRadius: 8, overflow: 'hidden' }}>
                    {[{ k: 'all', t: 'ทั้งหมด' }, { k: 'in', t: 'เงินเข้า' }, { k: 'out', t: 'เงินออก' }].map(d => (
                      <button key={d.k} onClick={() => setDir(d.k)} style={{ border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, padding: '5px 12px',
                        background: dir === d.k ? 'var(--brand-600)' : '#fff', color: dir === d.k ? '#fff' : 'var(--ink-600)', fontWeight: dir === d.k ? 700 : 500 }}>{d.t}</button>
                    ))}
                  </div>
                </div>
                {/* ── ตัวกรองผู้รับผิดชอบ — ฝ่ายการเงินกดดู "ของฉัน" เพื่อรู้ว่าตัวเองต้องบันทึกอะไรบ้าง ── */}
                <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 10, padding: '8px 12px', background: 'color-mix(in oklch, var(--brand-500) 5%, transparent)', borderRadius: 8 }}>
                  <span style={{ fontSize: 12, fontWeight: 700, color: 'var(--ink-700)' }}>👤 ผู้รับผิดชอบ:</span>
                  {[{ k: 'all', t: 'ทั้งหมด' }].concat(meUser ? [{ k: 'mine', t: '⭐ ของฉัน' }] : []).concat([{ k: 'none', t: 'ยังไม่มอบหมาย' }]).map(f => (
                    <button key={f.k} onClick={() => setAssignFilter(f.k)} style={{ border: '1px solid ' + (assignFilter === f.k ? 'var(--brand-600)' : 'var(--line)'), cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, padding: '4px 11px', borderRadius: 999,
                      background: assignFilter === f.k ? 'var(--brand-600)' : '#fff', color: assignFilter === f.k ? '#fff' : 'var(--ink-600)', fontWeight: assignFilter === f.k ? 700 : 500 }}>{f.t}</button>
                  ))}
                  {financeUsers.length > 0 && (
                    <select value={(['all', 'mine', 'none'].indexOf(assignFilter) >= 0) ? '' : assignFilter}
                      onChange={e => setAssignFilter(e.target.value || 'all')}
                      style={{ padding: '5px 8px', border: '1px solid var(--line)', borderRadius: 8, fontSize: 12, fontFamily: 'inherit', background: '#fff', marginLeft: 'auto' }}>
                      <option value="">— เลือกรายคน —</option>
                      {financeUsers.map(f => <option key={f.u} value={f.u}>{f.n || f.u}</option>)}
                    </select>
                  )}
                  {financeUsers.length === 0 && (
                    <span className="muted" style={{ fontSize: 11.5, marginLeft: 'auto' }}>ยังไม่มีผู้ใช้ฝ่าย "การเงิน" — ตั้งฝ่ายงานที่หน้าจัดการผู้ใช้ก่อน</span>
                  )}
                </div>
                <BRReconTable items={shownItems} maxH={tableMaxH} selectable={!readOnly} sel={selMerged} onToggle={toggleSel} showAssignee emptyText={assignFilter !== 'all' ? 'ไม่มีรายการค้างกระทบตามตัวกรองผู้รับผิดชอบ' : '✓ ไม่มีรายการค้างกระทบ'} />
              </div>
            )}

            {/* ── กระทบแล้ว — เลือกหลายรายการเพื่อยกเลิกการกระทบทีเดียว (แก้ auto-match สลับคู่) ── */}
            {tab === 'matched' && (
              <div>
                {!readOnly && matchItems.length > 0 && (
                  <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10, flexWrap: 'wrap', padding: '8px 12px', background: 'var(--ink-50)', borderRadius: 8 }}>
                    {(() => {
                      const allSel = shownItems.length > 0 && shownItems.every(it => selM[it.checkId]);
                      return (
                        <button onClick={() => setSelM(s => { const ns = Object.assign({}, s); shownItems.forEach(it => { if (allSel) delete ns[it.checkId]; else ns[it.checkId] = true; }); return ns; })}
                          style={{ fontSize: 12, padding: '6px 12px', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', border: '1px solid var(--line)', background: '#fff', color: 'var(--ink-700)', fontWeight: 600 }}>
                          {allSel ? 'ล้างที่เลือก' : 'เลือกทั้งหมด (' + shownItems.length + ')'}
                        </button>
                      );
                    })()}
                    <span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ink-700)' }}>เลือก {selMatchIds.length} รายการ</span>
                    <button disabled={!selMatchIds.length} onClick={doUnmatchMany}
                      style={{ background: selMatchIds.length ? 'var(--bad)' : 'var(--ink-200)', color: '#fff', border: 'none', borderRadius: 9, padding: '8px 16px', fontSize: 13, fontWeight: 700, cursor: selMatchIds.length ? 'pointer' : 'not-allowed', fontFamily: 'inherit' }}>
                      ↩ ยกเลิกที่เลือก ({selMatchIds.length})
                    </button>
                    <span style={{ fontSize: 12, color: 'var(--ink-500)' }}>รายการที่ยกเลิกจะกลับไปอยู่ถัง "รอกระทบยอด" เพื่อจับคู่ใหม่</span>
                  </div>
                )}
                <BRReconTable items={shownItems} maxH={tableMaxH} selectable={!readOnly} sel={selM} onToggle={toggleSelM}
                  emptyText="ยังไม่มีการจับคู่ที่ยืนยัน — ดูแท็บ 'รอยืนยัน'" />
              </div>
            )}

            {/* ── ทั้งหมด ── */}
            {tab === 'all' && <BRReconTable items={shownItems} maxH={tableMaxH} emptyText="ไม่มีรายการ" />}

            {/* ── Outstanding (เช็คค้างจากไฟล์ Mango) ── */}
            {tab === 'outstanding' && (outs.length === 0
              ? <BREmpty text="ไม่มีรายการ Outstanding Cheque ในไฟล์" />
              : <div style={{ maxHeight: tableMaxH, overflow: 'auto' }}>
                  <div style={{ fontSize: 12, color: 'var(--ink-600)', marginBottom: 8 }}>เช็ค/รายการที่ Mango บันทึกแล้ว แต่<b>ยังไม่ขึ้นธนาคาร</b> (ค้างจากเดือนก่อนๆ) — ตามไฟล์งบกระทบยอด</div>
                  <table className="tbl" style={{ width: '100%', fontSize: 12.5 }}>
                    <thead style={{ position: 'sticky', top: 0, background: 'var(--surface)', zIndex: 1 }}>
                      <tr><th style={{ width: 130 }}>เลขที่</th><th>ผู้รับ/รายการ</th><th style={{ width: 130, textAlign: 'right' }}>จำนวน</th></tr>
                    </thead>
                    <tbody>{outs.map((o, i) => (
                      <tr key={(o.id || o.vno || '') + '#' + i}>
                        <td style={{ fontFamily: 'ui-monospace', fontSize: 11.5 }}>{o.vno}</td>
                        <td>{o.vendor || '—'}</td>
                        <td style={{ textAlign: 'right' }}>{o.amount ? brAmtCell(o.amount) : <span style={{ color: 'var(--ink-300)' }}>—</span>}</td>
                      </tr>
                    ))}</tbody>
                  </table>
                </div>)}
          </div>
        </div>
      </div>)}
    </div>
  );
}

// ถัง (b) — statement ออก แต่ไม่มี PV → กดบันทึกจ่ายจริง
function BRMissingTable({ rows, readOnly, onRecord, onMatch, onMarkTransfer, isLikelyTransfer }) {
  if (!rows.length) return <BREmpty text="✓ ไม่มีรายการที่ขาดการบันทึก — statement ทุกรายการมี PV รองรับ" />;
  return (
    <div style={{ maxHeight: '52vh', overflow: 'auto' }}>
      <div style={{ fontSize: 12, color: 'var(--ink-600)', marginBottom: 8 }}>เงินออกจากบัญชีจริง แต่ยังไม่มี PV รองรับใน statement — กด <b>🔗 จับคู่ PV</b> (ถ้ามี PV ในระบบแต่เลขไม่ตรง เช่นแก้เอกสาร) · <b>บันทึกจ่ายจริง</b> (ยังไม่มี PV เลย) · <b>🔁 โอนระหว่างบัญชี</b> (โอนเข้าบัญชีตัวเอง)</div>
      <table className="tbl" style={{ width: '100%', fontSize: 12.5 }}>
        <thead style={{ position: 'sticky', top: 0, background: 'var(--surface)', zIndex: 1 }}>
          <tr><th style={{ width: 100 }}>วันที่</th><th>รายละเอียด</th><th style={{ width: 110 }}>อ้างอิง/เช็ค</th><th style={{ width: 120, textAlign: 'right' }}>จำนวน</th><th style={{ width: 280, textAlign: 'center' }}>จัดการ</th></tr>
        </thead>
        <tbody>
          {rows.map(l => {
            const hint = isLikelyTransfer && isLikelyTransfer(l);
            return (
            <tr key={l.id} style={hint ? { background: 'color-mix(in oklch, oklch(60% 0.13 250) 6%, transparent)' } : undefined}>
              <td style={{ whiteSpace: 'nowrap', color: 'var(--ink-600)' }}>{fmtDate(l.date) || l.date}</td>
              <td>{l.desc || '—'}{hint && <span style={{ marginLeft: 6, fontSize: 10.5, fontWeight: 700, color: 'oklch(50% 0.13 250)', background: 'color-mix(in oklch, oklch(60% 0.13 250) 16%, #fff)', borderRadius: 10, padding: '1px 7px', whiteSpace: 'nowrap' }}>🔁 น่าจะเป็นโอนระหว่างบัญชี</span>}</td>
              <td style={{ fontFamily: 'ui-monospace', fontSize: 11.5, color: 'var(--brand-700)' }}>{l.ref || '—'}</td>
              <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: 'var(--bad)' }}>{fmtNum(Math.abs(l.amount), 2)}</td>
              <td style={{ textAlign: 'center', whiteSpace: 'nowrap' }}>
                {!readOnly ? (<span style={{ display: 'inline-flex', gap: 5, justifyContent: 'center', flexWrap: 'wrap' }}>
                  {onMatch && <button title="ผูกกับ PV ในระบบเอง (เคสเลขเอกสารเปลี่ยน) — ไม่สร้าง Actual ซ้ำ"
                    style={{ fontSize: 11.5, padding: '4px 9px', borderRadius: 7, cursor: 'pointer', fontFamily: 'inherit',
                      border: '1px solid var(--good)', background: '#fff', color: 'var(--good)', fontWeight: 700 }}
                    onClick={() => onMatch(l)}>🔗 จับคู่ PV</button>}
                  <button className="btn btn-primary" style={{ fontSize: 11.5, padding: '4px 9px' }} onClick={() => onRecord(l)}>บันทึกจ่ายจริง</button>
                  {onMarkTransfer && <button title="เป็นการโอนเงินไปบัญชีตัวเอง — ไม่นับเป็นรายจ่าย"
                    style={{ fontSize: 11.5, padding: '4px 9px', borderRadius: 7, cursor: 'pointer', fontFamily: 'inherit',
                      border: '1px solid oklch(60% 0.13 250)', background: hint ? 'oklch(60% 0.13 250)' : '#fff', color: hint ? '#fff' : 'oklch(50% 0.13 250)', fontWeight: 600 }}
                    onClick={() => onMarkTransfer(l)}>🔁 โอนระหว่างบัญชี</button>}
                </span>) : <span style={{ color: 'var(--ink-300)' }}>—</span>}
              </td>
            </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

// ถัง — โอนระหว่างบัญชี (ทำเครื่องหมายแล้ว ไม่นับเป็นรายจ่าย) · กดยกเลิกกลับเป็นขาดบันทึกได้
function BRTransfersTable({ rows, readOnly, onUndo }) {
  if (!rows.length) return <BREmpty text="ยังไม่มีรายการที่ทำเครื่องหมายเป็นโอนระหว่างบัญชี" />;
  return (
    <div style={{ maxHeight: '52vh', overflow: 'auto' }}>
      <div style={{ fontSize: 12, color: 'var(--ink-600)', marginBottom: 8 }}>รายการโอนเงินระหว่างบัญชีตัวเอง — ไม่นับเป็นรายจ่าย ไม่เข้า Actual</div>
      <table className="tbl" style={{ width: '100%', fontSize: 12.5 }}>
        <thead style={{ position: 'sticky', top: 0, background: 'var(--surface)', zIndex: 1 }}>
          <tr><th style={{ width: 100 }}>วันที่</th><th>รายละเอียด</th><th style={{ width: 110 }}>อ้างอิง/เช็ค</th><th style={{ width: 130, textAlign: 'right' }}>จำนวน</th><th style={{ width: 110, textAlign: 'center' }}>จัดการ</th></tr>
        </thead>
        <tbody>
          {rows.map(l => (
            <tr key={l.id}>
              <td style={{ whiteSpace: 'nowrap', color: 'var(--ink-600)' }}>{fmtDate(l.date) || l.date}</td>
              <td>{l.desc || '—'}</td>
              <td style={{ fontFamily: 'ui-monospace', fontSize: 11.5, color: 'var(--brand-700)' }}>{l.ref || '—'}</td>
              <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: 'var(--ink-500)' }}>{fmtNum(Math.abs(l.amount), 2)}</td>
              <td style={{ textAlign: 'center' }}>
                {!readOnly
                  ? <button className="btn btn-ghost" style={{ fontSize: 11.5, padding: '4px 10px' }} onClick={() => onUndo(l, '')}>↩ ยกเลิก</button>
                  : <span style={{ color: 'var(--ink-300)' }}>—</span>}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

// ถัง (c) — PV ในระบบ แต่ยังไม่เจอใน statement
function BRPvTable({ rows }) {
  if (!rows.length) return <BREmpty text="✓ PV ทุกใบเจอใน statement แล้ว" />;
  return (
    <div style={{ maxHeight: '52vh', overflow: 'auto' }}>
      <div style={{ fontSize: 12, color: 'var(--ink-600)', marginBottom: 8 }}>มี PV ในระบบ แต่ยังไม่เจอรายการตรงกันใน statement (เช็คยังไม่ขึ้นเงิน / ยังไม่ถึงรอบ / เลขไม่ตรง)</div>
      <table className="tbl" style={{ width: '100%', fontSize: 12.5 }}>
        <thead style={{ position: 'sticky', top: 0, background: 'var(--surface)', zIndex: 1 }}>
          <tr><th style={{ width: 100 }}>วันที่จ่าย</th><th style={{ width: 120 }}>เลขที่ PV</th><th>ผู้รับเงิน</th><th style={{ width: 100 }}>เช็ค</th><th style={{ width: 130, textAlign: 'right' }}>จำนวน</th></tr>
        </thead>
        <tbody>
          {rows.map(p => (
            <tr key={p.id || (p.pvNo + p.date)}>
              <td style={{ whiteSpace: 'nowrap', color: 'var(--ink-600)' }}>{fmtDate(p.date) || p.date}</td>
              <td style={{ fontFamily: 'ui-monospace', fontSize: 11.5 }}>{p.pvNo || '—'}</td>
              <td>{p.payee || '—'}</td>
              <td style={{ fontFamily: 'ui-monospace', fontSize: 11 }}>{p.chqNo || '—'}</td>
              <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: 'var(--warn)' }}>{fmtNum(Math.abs(p.amount), 2)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

// ถัง (a) — แมตช์แล้ว
function BRMatchedTable({ rows, readOnly, onUndo }) {
  if (!rows.length) return <BREmpty text="ยังไม่มีรายการที่แมตช์" />;
  const hasManual = rows.some(m => m.manual);
  return (
    <div style={{ maxHeight: '52vh', overflow: 'auto' }}>
      <table className="tbl" style={{ width: '100%', fontSize: 12.5 }}>
        <thead style={{ position: 'sticky', top: 0, background: 'var(--surface)', zIndex: 1 }}>
          <tr><th style={{ width: 100 }}>วันที่</th><th>รายการ statement</th><th style={{ width: 150 }}>PV</th><th style={{ width: 64, textAlign: 'center' }}>วิธี</th><th style={{ width: 130, textAlign: 'right' }}>จำนวน</th>{hasManual && <th style={{ width: 90, textAlign: 'center' }}>จัดการ</th>}</tr>
        </thead>
        <tbody>
          {rows.map((m, i) => {
            const pvList = m.pvs || [m.pv];
            const pvNos = [...new Set(pvList.map(p => p.pvNo || p.apNo || '—'))];
            const viaLabel = m.via === 'manual' ? 'จับคู่เอง' : m.via === 'ref' ? 'PV' : m.via === 'split' ? 'รวม' : '1:1';
            const viaWarn = m.via === 'split';
            const viaManual = m.via === 'manual';
            return (
              <tr key={m.line.id || ('g' + i)} style={viaManual ? { background: 'color-mix(in oklch, var(--good) 5%, transparent)' } : undefined}>
                <td style={{ whiteSpace: 'nowrap', color: 'var(--ink-600)' }}>{fmtDate(m.line.date) || m.line.date}</td>
                <td>{m.line.desc || '—'} {m.line.ref && <span style={{ fontFamily: 'ui-monospace', fontSize: 11, color: 'var(--ink-400)' }}>· {m.line.ref}</span>}
                  {m.amtMismatch ? <span style={{ color: 'var(--warn)', fontSize: 11 }}> · ⚠️ ยอดต่าง {fmtNum(Math.abs(m.amtMismatch), 2)}</span> : null}</td>
                <td style={{ fontFamily: 'ui-monospace', fontSize: 11 }}>{pvNos.join(', ')}</td>
                <td style={{ textAlign: 'center' }}>
                  <span style={{ fontSize: 10, fontWeight: 700, color: viaManual ? 'oklch(50% 0.13 250)' : viaWarn ? 'var(--warn)' : 'var(--good)', background: viaManual ? 'color-mix(in oklch, oklch(60% 0.13 250) 14%, #fff)' : viaWarn ? 'var(--warn-bg)' : 'var(--good-bg)', padding: '2px 6px', borderRadius: 4, whiteSpace: 'nowrap' }}>{viaLabel}</span>
                </td>
                <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: 'var(--good)' }}>{fmtNum(Math.abs(m.line.amount), 2)}</td>
                {hasManual && <td style={{ textAlign: 'center' }}>
                  {viaManual && !readOnly ? <button className="btn btn-ghost" style={{ fontSize: 11, padding: '3px 8px', color: 'var(--bad)' }} onClick={() => onUndo(m.line, '')}>↩ ยกเลิก</button> : <span style={{ color: 'var(--ink-300)' }}>—</span>}
                </td>}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

// ถังที่บันทึกจาก statement แล้ว (เด้งเข้า cashflow แล้ว)
function BRRecordedTable({ rows, readOnly, onUndo, onMatch }) {
  if (!rows.length) return <BREmpty text="ยังไม่มีรายการที่บันทึกจ่ายจริงจาก statement" />;
  return (
    <div style={{ maxHeight: '52vh', overflow: 'auto' }}>
      <div style={{ fontSize: 12, color: 'var(--ink-600)', marginBottom: 8 }}>บันทึกเป็น "จ่ายจริง" จาก statement แล้ว (เป็น Actual หน้า Cashflow) — ถ้าจริงๆ มี <b>PV ในระบบ</b> รองรับอยู่แล้ว (เลขเอกสารเปลี่ยนเลย auto-match ไม่เจอ) ให้กด <b>🔗 จับคู่ PV แทน</b> เพื่อกันยอดนับซ้ำ</div>
      <table className="tbl" style={{ width: '100%', fontSize: 12.5 }}>
        <thead style={{ position: 'sticky', top: 0, background: 'var(--surface)', zIndex: 1 }}>
          <tr><th style={{ width: 100 }}>วันที่</th><th>รายละเอียด</th><th style={{ width: 130, textAlign: 'right' }}>จำนวน</th><th style={{ width: 210, textAlign: 'center' }}>จัดการ</th></tr>
        </thead>
        <tbody>
          {rows.map(r => (
            <tr key={r.line.id}>
              <td style={{ whiteSpace: 'nowrap', color: 'var(--ink-600)' }}>{fmtDate(r.line.date) || r.line.date}</td>
              <td>{r.line.desc || '—'} <span style={{ fontSize: 11, color: 'var(--good)' }}>✓ เป็น Actual แล้ว</span></td>
              <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 700, color: 'var(--ink-700)' }}>{fmtNum(Math.abs(r.line.amount), 2)}</td>
              <td style={{ textAlign: 'center', whiteSpace: 'nowrap' }}>
                {!readOnly ? (<span style={{ display: 'inline-flex', gap: 5, justifyContent: 'center', flexWrap: 'wrap' }}>
                  {onMatch && <button title="จริงๆ มี PV รองรับ — ผูกกับ PV แทน (เลิกนับเป็น Actual ซ้ำ)"
                    style={{ fontSize: 11, padding: '3px 8px', borderRadius: 7, cursor: 'pointer', fontFamily: 'inherit', border: '1px solid var(--good)', background: '#fff', color: 'var(--good)', fontWeight: 700 }}
                    onClick={() => onMatch(r.line)}>🔗 จับคู่ PV แทน</button>}
                  <button className="btn btn-ghost" style={{ fontSize: 11, padding: '3px 8px', color: 'var(--bad)' }} onClick={() => onUndo(r.line, r.forecastId)}>↩ ยกเลิก</button>
                </span>) : <span style={{ color: 'var(--ink-300)' }}>—</span>}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

// ─── ตารางรายการเดินบัญชี (statement) ──────────────────────────────────────────
function BRStatementTable({ monthly }) {
  return (
    <div className="card anim-in" style={{ padding: 0, overflow: 'hidden' }}>
      <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--line)', fontWeight: 700, fontSize: 13, color: 'var(--ink-800)', display: 'flex', justifyContent: 'space-between' }}>
        <span>รายการเดินบัญชี (statement) · {monthly.count} รายการ</span>
        <span style={{ fontSize: 12, fontWeight: 500, color: 'var(--ink-500)' }}>ยกมา {fmtNum(monthly.opening || 0, 0)} → คงเหลือ {fmtNum(monthly.closing || 0, 0)}</span>
      </div>
      <div style={{ maxHeight: '60vh', overflow: 'auto' }}>
        <table className="tbl" style={{ width: '100%', fontSize: 12.5 }}>
          <thead style={{ position: 'sticky', top: 0, background: 'var(--surface)', zIndex: 1 }}>
            <tr><th style={{ width: 100 }}>วันที่</th><th>รายละเอียด</th><th style={{ width: 110 }}>อ้างอิง</th><th style={{ width: 120, textAlign: 'right' }}>จ่ายออก</th><th style={{ width: 120, textAlign: 'right' }}>รับเข้า</th><th style={{ width: 130, textAlign: 'right' }}>คงเหลือ</th></tr>
          </thead>
          <tbody>
            {monthly.sorted.map(l => (
              <tr key={l.id}>
                <td style={{ whiteSpace: 'nowrap', color: 'var(--ink-600)' }}>{fmtDate(l.date) || l.date}</td>
                <td>{l.desc || '—'}</td>
                <td style={{ fontFamily: 'ui-monospace', fontSize: 11, color: 'var(--ink-500)' }}>{l.ref || ''}</td>
                <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', color: 'var(--bad)' }}>{l.amount < 0 ? fmtNum(-l.amount, 2) : ''}</td>
                <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', color: 'var(--good)' }}>{l.amount > 0 ? fmtNum(l.amount, 2) : ''}</td>
                <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', color: 'var(--ink-700)', fontWeight: 600 }}>{fmtNum(l.running, 2)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

// ─── Import modal — โยนไฟล์หลายไฟล์ + ตารางสรุป (ไฟล์→บัญชี→กี่รายการ) → นำเข้าทีเดียว ──────────
function BRImportModal({ accounts, defaultAcct, buildMapping, onCommit, onClose, toast }) {
  const [items, setItems] = brState([]);     // [{key,name,status,accountNo,brand,mapping,sheets,sheetNames,sheetIdx,aoa,lines,months,error}]
  const [editKey, setEditKey] = brState(null);
  const [drag, setDrag] = brState(false);
  const inputRef = brRef(null);
  const seqRef = brRef(0);

  // คำนวณ aoa (จาก sheet ที่เลือก) + lines ใหม่จาก mapping/บัญชี
  const recompute = (it) => {
    if (!it.sheetNames || !it.sheetNames.length || !it.mapping) return it;
    const aoa = it.sheets[it.sheetNames[it.sheetIdx || 0]] || [];
    const lines = brNormalizeLines(aoa, it.mapping, it.accountNo);
    const mset = {}; lines.forEach(l => { mset[brMonthOf(l.date)] = 1; });
    return { ...it, aoa, lines, months: Object.keys(mset).sort(), status: lines.length ? 'ready' : 'empty' };
  };

  const addFiles = (files) => {
    Array.prototype.slice.call(files || []).forEach(file => {
      const key = 'f' + (seqRef.current++);
      setItems(prev => [...prev, { key, name: file.name, status: 'reading',
        accountNo: (defaultAcct && defaultAcct.accountNo) || (accounts[0] && accounts[0].accountNo) || '',
        brand: '', mapping: null, sheets: {}, sheetNames: [], sheetIdx: 0, aoa: null, lines: [], months: [] }]);
      brParseFile(file, null).then(({ sheetNames, sheets }) => {
        const aoa = sheets[sheetNames[0]] || [];
        const a = brDetectAccountForFile(file.name, accounts, defaultAcct) || accounts[0];
        const bm = buildMapping(aoa, a);
        setItems(prev => prev.map(it => it.key === key
          ? recompute({ ...it, accountNo: a.accountNo, brand: bm.brand, mapping: bm.mapping, sheets, sheetNames, sheetIdx: 0 })
          : it));
      }).catch(err => {
        const msg = String((err && err.message) || err).toLowerCase();
        const pwd = /password|encrypt|protect/.test(msg);
        setItems(prev => prev.map(it => it.key === key ? { ...it, status: pwd ? 'pwd' : 'error',
          error: pwd ? 'ไฟล์มีรหัส — เปิดใน Excel แล้ว Save As .xlsx/.csv (ไม่ใส่รหัส) ก่อน' : 'อ่านไฟล์ไม่ได้ — รูปแบบไม่รองรับ' } : it));
      });
    });
  };
  const setAcct = (key, accNo) => setItems(prev => prev.map(it => {
    if (it.key !== key) return it;
    const a = accounts.find(x => x.accountNo === accNo) || { accountNo: accNo };
    const aoa = (it.sheets && it.sheetNames.length) ? (it.sheets[it.sheetNames[it.sheetIdx || 0]] || []) : [];
    const bm = aoa.length ? buildMapping(aoa, a) : { brand: it.brand, mapping: it.mapping };
    return recompute({ ...it, accountNo: accNo, brand: bm.brand, mapping: bm.mapping });
  }));
  const setMapping = (key, mapping) => setItems(prev => prev.map(it => it.key === key ? recompute({ ...it, mapping }) : it));
  const changeSheet = (key, idx) => setItems(prev => prev.map(it => {
    if (it.key !== key) return it;
    const aoa = it.sheets[it.sheetNames[idx]] || [];
    const hr = brGuessHeaderRow(aoa);
    return recompute({ ...it, sheetIdx: idx, mapping: { ...it.mapping, headerRow: hr } });
  }));
  const removeItem = (key) => setItems(prev => prev.filter(it => it.key !== key));

  const ready = items.filter(it => it.status === 'ready');
  const editItem = items.find(it => it.key === editKey) || null;
  const onDrop = (e) => { e.preventDefault(); setDrag(false); if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files); };

  return (
    <Modal open maxWidth={880} onClose={onClose} title="📥 นำเข้า statement"
      footer={<>
        <span style={{ marginRight: 'auto', fontSize: 12, color: ready.length ? 'var(--good)' : 'var(--ink-500)' }}>
          {items.length ? `${ready.length}/${items.length} ไฟล์พร้อมนำเข้า` : 'ยังไม่ได้เลือกไฟล์'}
        </span>
        <button className="btn btn-ghost" onClick={onClose}>ยกเลิก</button>
        <button className="btn btn-primary" disabled={!ready.length} onClick={() => onCommit(ready)}>นำเข้าทั้งหมด{ready.length ? ' (' + ready.length + ')' : ''}</button>
      </>}>
      {/* drop zone */}
      <div onDragOver={e => { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={onDrop}
        onClick={() => inputRef.current && inputRef.current.click()}
        style={{ border: `2px dashed ${drag ? 'var(--brand-500)' : 'var(--line)'}`, borderRadius: 12, padding: '20px 16px', textAlign: 'center',
          cursor: 'pointer', background: drag ? 'color-mix(in oklch, var(--brand-500) 9%, #fff)' : 'var(--ink-50)', marginBottom: 14, transition: 'all .15s' }}>
        <div style={{ fontSize: 28, marginBottom: 2 }}>📂</div>
        <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--ink-700)' }}>ลากไฟล์มาวางที่นี่ หรือกดเพื่อเลือก</div>
        <div style={{ fontSize: 11.5, color: 'var(--ink-500)', marginTop: 3 }}>เลือกได้หลายไฟล์/หลายบัญชี · CSV / Excel · ระบบเดาบัญชีจากชื่อไฟล์ให้ (เปลี่ยนได้)</div>
        <input ref={inputRef} type="file" multiple accept=".csv,.xls,.xlsx,.txt" style={{ display: 'none' }}
          onChange={e => { addFiles(e.target.files); e.target.value = ''; }} />
      </div>
      {/* คู่มือไฟล์ที่ใช้นำเข้า (ย้ายมาจากหน้าหลัก — พับเก็บได้) */}
      <BRImportHelp />
      {/* ตารางสรุปต่อไฟล์ */}
      {items.length > 0 && (
        <div style={{ border: '1px solid var(--line)', borderRadius: 10, overflow: 'hidden' }}>
          <table className="tbl" style={{ width: '100%', fontSize: 12, tableLayout: 'fixed' }}>
            <thead><tr>
              <th style={{ textAlign: 'left', width: '30%' }}>ไฟล์</th>
              <th style={{ textAlign: 'left', width: '27%' }}>เข้าบัญชี</th>
              <th style={{ textAlign: 'right', width: '12%' }}>รายการ</th>
              <th style={{ textAlign: 'left', width: '21%' }}>สถานะ</th>
              <th style={{ width: '10%' }}></th>
            </tr></thead>
            <tbody>
              {items.map(it => (
                <tr key={it.key}>
                  <td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={it.name}>{it.name}</td>
                  <td>
                    {it.status === 'reading' ? <span style={{ color: 'var(--ink-400)' }}>—</span> : (
                      <select className="select input" value={it.accountNo} onChange={e => setAcct(it.key, e.target.value)}
                        style={{ fontSize: 11.5, padding: '3px 6px', width: '100%' }} disabled={it.status === 'pwd' || it.status === 'error'}>
                        {accounts.map(a => <option key={a.accountNo} value={a.accountNo}>{bdBrand(brBrandKey(a)).label} ···{bdLast4(a.accountNo)}</option>)}
                      </select>
                    )}
                  </td>
                  <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
                    {it.status === 'ready' ? it.lines.length : (it.status === 'reading' ? '…' : '0')}
                  </td>
                  <td style={{ fontSize: 11.5 }}>
                    {it.status === 'reading' && <span style={{ color: 'var(--ink-400)' }}>⏳ กำลังอ่าน…</span>}
                    {it.status === 'ready' && <span style={{ color: 'var(--good)' }}>✓ {it.months.map(brFmtMonth).join(', ')}</span>}
                    {it.status === 'empty' && <span style={{ color: 'var(--warn)' }}>⚠ อ่านได้ 0 — กด ⚙ ปรับคอลัมน์</span>}
                    {it.status === 'error' && <span style={{ color: 'var(--bad)' }} title={it.error}>✕ อ่านไม่ได้</span>}
                    {it.status === 'pwd' && <span style={{ color: 'var(--bad)' }} title={it.error}>🔒 มีรหัส</span>}
                  </td>
                  <td style={{ whiteSpace: 'nowrap', textAlign: 'right' }}>
                    {(it.status === 'ready' || it.status === 'empty') &&
                      <button className="btn btn-ghost" style={{ padding: '2px 7px', fontSize: 11 }} onClick={() => setEditKey(it.key)} title="ปรับคอลัมน์/แถว header">⚙</button>}
                    <button className="btn btn-ghost" style={{ padding: '2px 7px', fontSize: 11, color: 'var(--bad)' }} onClick={() => removeItem(it.key)} title="เอาออก">✕</button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
      {items.some(it => it.status === 'pwd') && (
        <div style={{ marginTop: 10, fontSize: 11.5, color: 'var(--ink-700)', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 8, padding: '8px 12px', lineHeight: 1.7 }}>
          🔒 ไฟล์ที่มีรหัส (เช่น KTB) เบราว์เซอร์ถอดไม่ได้ — เปิดใน <b>Excel</b> แล้ว <b>Save As .xlsx/.csv (ไม่ใส่รหัส)</b> แล้วลากเข้ามาใหม่
        </div>
      )}
      {/* ตัวแก้คอลัมน์รายไฟล์ (reuse BRMappingModal) */}
      {editItem && editItem.aoa && editItem.mapping && (
        <BRMappingModal accounts={accounts}
          preview={{ fileName: editItem.name, sheetNames: editItem.sheetNames, sheets: editItem.sheets, sheetIdx: editItem.sheetIdx || 0,
            aoa: editItem.aoa, headerRow: editItem.mapping.headerRow, mapping: editItem.mapping, brand: editItem.brand, accountNo: editItem.accountNo,
            _total: 1, _index: 1, _rest: [] }}
          onApply={(m) => { setMapping(editItem.key, m); setEditKey(null); }}
          onChangeAccount={(accNo) => setAcct(editItem.key, accNo)}
          onChangeSheet={(idx) => changeSheet(editItem.key, idx)}
          onClose={() => setEditKey(null)} />
      )}
    </Modal>
  );
}

// ─── Mapping modal — preview + เลือกคอลัมน์ ────────────────────────────────────
function BRMappingModal({ preview, accounts, onApply, onClose, onChangeSheet, onChangeAccount, onSkip }) {
  const [m, setM] = brState(preview.mapping);
  brEffect(() => { setM(preview.mapping); }, [preview.headerRow, preview.sheetIdx, preview.accountNo]);
  const headers = (preview.aoa[m.headerRow] || []);
  const colOpts = headers.map((h, i) => ({ i, label: `${i + 1}. ${String(h || '').slice(0, 28) || '(ว่าง)'}` }));
  const sample = preview.aoa.slice(m.headerRow + 1, m.headerRow + 9);
  const up = (patch) => setM({ ...m, ...patch });
  const sel = (val, onCh, allowNone) => (
    <select className="select input" value={val == null ? '' : val} onChange={e => onCh(e.target.value === '' ? null : Number(e.target.value))}
      style={{ fontSize: 12, padding: '4px 8px', minWidth: 150 }}>
      {allowNone && <option value="">— ไม่มี —</option>}
      {colOpts.map(c => <option key={c.i} value={c.i}>{c.label}</option>)}
    </select>
  );
  const previewLines = brNormalizeLines(preview.aoa, m, preview.accountNo || '_preview');
  const total = preview._total || 1, idx = preview._index || 1, hasRest = (preview._rest || []).length > 0;
  return (
    <Modal open maxWidth={920} onClose={onClose}
      title={(total > 1 ? `ไฟล์ ${idx}/${total} · ` : '') + 'ตั้งค่าคอลัมน์ statement · ' + preview.fileName}
      footer={<>
        <span style={{ marginRight: 'auto', fontSize: 12, color: previewLines.length ? 'var(--good)' : 'var(--bad)' }}>
          {previewLines.length ? `อ่านได้ ${previewLines.length} รายการ` : 'ยังอ่านไม่ได้ — ปรับคอลัมน์/แถว header'}
        </span>
        <button className="btn btn-ghost" onClick={onClose}>{total > 1 ? 'ยกเลิกทั้งหมด' : 'ยกเลิก'}</button>
        {hasRest && <button className="btn btn-ghost" onClick={onSkip} title="ไม่นำเข้าไฟล์นี้ ไปไฟล์ถัดไป">ข้ามไฟล์นี้ →</button>}
        <button className="btn btn-primary" disabled={!previewLines.length} onClick={() => onApply(m)}>นำเข้า {previewLines.length || ''}{hasRest ? ' → ถัดไป' : ''}</button>
      </>}>
      {/* นำเข้าเข้าบัญชีไหน — เดาจากชื่อไฟล์ เปลี่ยนได้ (สำคัญตอนนำเข้าหลายไฟล์) */}
      <div style={{ marginBottom: 12, padding: '9px 12px', background: 'color-mix(in oklch, var(--brand-500) 7%, #fff)', border: '1px solid color-mix(in oklch, var(--brand-500) 22%, var(--line))', borderRadius: 9, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
        <span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--brand-700)' }}>🏦 นำเข้าเข้าบัญชี:</span>
        <select className="select input" value={preview.accountNo} onChange={e => onChangeAccount(e.target.value)}
          style={{ fontSize: 12.5, padding: '5px 10px', fontWeight: 600, minWidth: 220 }}>
          {(accounts || []).map(a => (
            <option key={a.accountNo} value={a.accountNo}>{bdBrand(brBrandKey(a)).label} ···{bdLast4(a.accountNo)}{a.accountName ? ' · ' + a.accountName : ''}</option>
          ))}
        </select>
        <span style={{ fontSize: 11, color: 'var(--ink-500)' }}>(เดาจากชื่อไฟล์ — ถ้าไม่ตรง เลือกใหม่ได้)</span>
      </div>
      {preview.sheetNames.length > 1 && (
        <div style={{ marginBottom: 10, fontSize: 12.5 }}>ชีต:{' '}
          <select className="select input" value={preview.sheetIdx} onChange={e => onChangeSheet(Number(e.target.value))} style={{ fontSize: 12, padding: '4px 8px' }}>
            {preview.sheetNames.map((n, i) => <option key={i} value={i}>{n}</option>)}
          </select>
        </div>
      )}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px 16px', marginBottom: 14, alignItems: 'center', fontSize: 12.5 }}>
        <label>แถว header (1=แรกสุด): <input type="number" min={1} value={m.headerRow + 1} onChange={e => up({ headerRow: Math.max(0, Number(e.target.value) - 1) })} style={{ width: 60, padding: '3px 6px', marginLeft: 6 }} /></label>
        <label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>คอลัมน์วันที่: {sel(m.dateCol, v => up({ dateCol: v }))}</label>
        <div style={{ gridColumn: '1 / -1', display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap', borderTop: '1px dashed var(--line)', paddingTop: 10 }}>
          <label><input type="radio" checked={m.mode === 'split'} onChange={() => up({ mode: 'split' })} /> เดบิต/เครดิต แยกคอลัมน์</label>
          <label><input type="radio" checked={m.mode === 'single'} onChange={() => up({ mode: 'single' })} /> ยอดคอลัมน์เดียว</label>
        </div>
        {m.mode === 'split' ? (
          <>
            <label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>เดบิต (จ่ายออก): {sel(m.debitCol, v => up({ debitCol: v }), true)}</label>
            <label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>เครดิต (รับเข้า): {sel(m.creditCol, v => up({ creditCol: v }), true)}</label>
          </>
        ) : (
          <>
            <label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>คอลัมน์ยอด: {sel(m.amountCol, v => up({ amountCol: v }))}</label>
            <label><input type="checkbox" checked={!!m.outflowPositive} onChange={e => up({ outflowPositive: e.target.checked })} /> ยอดจ่ายออกเป็นเลขบวก</label>
          </>
        )}
        <label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>รายละเอียด: {sel(m.descCol, v => up({ descCol: v }), true)}</label>
        <label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>เลขที่/เช็ค: {sel(m.refCol, v => up({ refCol: v }), true)}</label>
        <label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>ยอดคงเหลือ: {sel(m.balanceCol, v => up({ balanceCol: v }), true)}</label>
      </div>
      <div style={{ fontSize: 11.5, color: 'var(--ink-500)', marginBottom: 6 }}>ตัวอย่างข้อมูล (8 แถวแรกหลัง header):</div>
      <div style={{ maxHeight: 220, overflow: 'auto', border: '1px solid var(--line)', borderRadius: 6 }}>
        <table className="tbl" style={{ width: '100%', fontSize: 11 }}>
          <thead><tr>{headers.map((h, i) => <th key={i} style={{ whiteSpace: 'nowrap' }}>{i + 1}. {String(h || '').slice(0, 18)}</th>)}</tr></thead>
          <tbody>
            {sample.map((r, ri) => <tr key={ri}>{headers.map((_, ci) => <td key={ci} style={{ whiteSpace: 'nowrap', maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis' }}>{r[ci] instanceof Date ? bdISO(r[ci]) : String(r[ci] == null ? '' : r[ci]).slice(0, 22)}</td>)}</tr>)}
          </tbody>
        </table>
      </div>
    </Modal>
  );
}

// ─── Record-actual modal ──────────────────────────────────────────────────────
function BRRecordModal({ line, onSave, onClose }) {
  const [cat, setCat] = brState('1');
  return (
    <Modal open title="บันทึกเป็นจ่ายจริง" maxWidth={460} onClose={onClose}
      footer={<>
        <button className="btn btn-ghost" onClick={onClose}>ยกเลิก</button>
        <button className="btn btn-primary" onClick={() => onSave(line, cat)}>บันทึก → ส่งเข้า Cashflow</button>
      </>}>
      <div style={{ fontSize: 12.5, color: 'var(--ink-600)', marginBottom: 12 }}>
        รายการนี้จะถูกบันทึกเป็น "จ่ายจริง" (forecastEntries STATUS=ACTUAL) และไปแสดงในช่อง Actual หน้า Cashflow ตามสัปดาห์/หมวดที่เลือก
      </div>
      <table className="tbl" style={{ width: '100%', fontSize: 13, marginBottom: 12 }}>
        <tbody>
          <tr><td style={{ color: 'var(--ink-500)', width: 110 }}>วันที่</td><td style={{ fontWeight: 600 }}>{fmtDate(line.date) || line.date}</td></tr>
          <tr><td style={{ color: 'var(--ink-500)' }}>รายละเอียด</td><td style={{ fontWeight: 600 }}>{line.desc || '—'}</td></tr>
          <tr><td style={{ color: 'var(--ink-500)' }}>อ้างอิง/เช็ค</td><td>{line.ref || '—'}</td></tr>
          <tr><td style={{ color: 'var(--ink-500)' }}>จำนวน</td><td style={{ fontWeight: 700, color: 'var(--bad)' }}>{fmtNum(Math.abs(line.amount), 2)}</td></tr>
        </tbody>
      </table>
      <label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, marginBottom: 6 }}>หมวดค่าใช้จ่าย</label>
      <select className="select input" value={cat} onChange={e => setCat(e.target.value)} style={{ width: '100%', fontSize: 13, padding: '7px 10px' }}>
        {BD_CF_CATEGORIES.map(c => <option key={c.code} value={c.code}>{c.code} · {c.label}</option>)}
      </select>
    </Modal>
  );
}

// ── Modal จับคู่ PV เอง — เลือก PV ในระบบมาผูกกับบรรทัด statement (เคสเลขเอกสารเปลี่ยน) ──
function BRMatchModal({ line, candidates, allPvs, onMatch, onClose }) {
  const target = Math.abs(Number(line.amount) || 0);
  const keyOf = p => (p.id != null && p.id !== '' ? 'i' + p.id : 'p' + (p.pvNo || ''));
  const closeness = (p) => {
    const ad = Math.abs((Math.abs(Number(p.amount) || 0)) - target);
    const dd = p.date ? brDateDiff(line.date, p.date) : 999;
    return ad * 1000 + dd;   // ยอดต่างมาก่อน แล้วค่อยวันที่
  };
  const sortedCand = brMemo(() => (candidates || []).slice().sort((a, b) => closeness(a) - closeness(b)), [candidates, line.id]);
  const [q, setQ] = brState('');
  const qn = q.trim().toLowerCase();
  const searchHits = brMemo(() => {
    if (!qn) return [];
    const qDigits = qn.replace(/[,\s]/g, '');
    return (allPvs || []).filter(p =>
      String(p.pvNo || '').toLowerCase().indexOf(qn) >= 0 ||
      String(p.payee || '').toLowerCase().indexOf(qn) >= 0 ||
      (qDigits && String(Math.abs(Number(p.amount) || 0)).indexOf(qDigits) >= 0)
    ).sort((a, b) => closeness(a) - closeness(b)).slice(0, 60);
  }, [qn, allPvs, line.id]);
  const list = qn ? searchHits : sortedCand;
  const best = sortedCand[0] && Math.abs(Math.abs(Number(sortedCand[0].amount) || 0) - target) < 0.01 ? sortedCand[0] : null;
  const [sel, setSel] = brState(best || null);
  return (
    <Modal open title="🔗 จับคู่ PV เอง" maxWidth={620} onClose={onClose}
      footer={<>
        <button className="btn btn-ghost" onClick={onClose}>ยกเลิก</button>
        <button className="btn btn-primary" disabled={!sel} onClick={() => sel && onMatch(line, sel)}>จับคู่กับ PV นี้</button>
      </>}>
      <div style={{ fontSize: 12.5, color: 'var(--ink-600)', marginBottom: 10 }}>
        ผูกบรรทัดนี้กับ PV ในระบบที่เป็นการจ่ายก้อนเดียวกัน (เคสแก้เอกสาร เลขที่ PV เปลี่ยน เลย auto-match ไม่เจอ) — <b>ไม่สร้างรายการ Actual ใหม่</b> เพราะ PV เป็น Actual อยู่แล้ว ป้องกันยอดนับซ้ำ
      </div>
      {/* บรรทัด statement ที่จะจับคู่ */}
      <div style={{ background: 'var(--ink-50)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontSize: 12.5 }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
          <span style={{ color: 'var(--ink-600)' }}>{fmtDate(line.date) || line.date} · {line.desc || '—'}{line.ref ? ' · ' + line.ref : ''}</span>
          <b style={{ color: 'var(--bad)', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{fmtNum(target, 2)}</b>
        </div>
      </div>
      <input className="input" value={q} onChange={e => setQ(e.target.value)} placeholder="🔍 ค้นทุก PV ในระบบ (เลขที่ PV / ผู้รับเงิน / ยอด)…"
        style={{ width: '100%', fontSize: 13, padding: '7px 10px', marginBottom: 8 }} />
      <div style={{ fontSize: 11.5, color: 'var(--ink-500)', marginBottom: 6 }}>
        {qn ? `ผลค้นหา ${list.length} รายการ` : `PV ที่ยังไม่จับคู่ในบัญชี/เดือนนี้ ${list.length} รายการ (เรียงตามความใกล้เคียงยอด+วันที่)`}
      </div>
      <div style={{ maxHeight: '40vh', overflow: 'auto', border: '1px solid var(--line)', borderRadius: 8 }}>
        {list.length === 0 ? (
          <div style={{ padding: 18, textAlign: 'center', color: 'var(--ink-400)', fontSize: 12.5 }}>
            {qn ? 'ไม่พบ PV ที่ตรงกับคำค้น' : 'ไม่มี PV ค้างในบัญชี/เดือนนี้ — พิมพ์ค้นหาด้านบนเพื่อหา PV จากทุกบัญชี/เดือน'}
          </div>
        ) : list.map(p => {
          const k = keyOf(p);
          const active = sel && keyOf(sel) === k;
          const amt = Math.abs(Number(p.amount) || 0);
          const exact = Math.abs(amt - target) < 0.01;
          const dd = p.date ? brDateDiff(line.date, p.date) : null;
          return (
            <button key={k} type="button" onClick={() => setSel(p)}
              style={{ display: 'block', width: '100%', textAlign: 'left', cursor: 'pointer', fontFamily: 'inherit',
                border: 'none', borderBottom: '1px solid var(--line)', padding: '8px 12px',
                background: active ? 'color-mix(in oklch, var(--good) 12%, #fff)' : '#fff' }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' }}>
                <span style={{ fontSize: 12.5 }}>
                  <b style={{ fontFamily: 'ui-monospace' }}>{p.pvNo || '—'}</b>
                  <span style={{ color: 'var(--ink-600)' }}> · {p.payee || '—'}</span>
                  {p.chqNo ? <span style={{ color: 'var(--ink-400)', fontFamily: 'ui-monospace', fontSize: 11 }}> · เช็ค {p.chqNo}</span> : null}
                </span>
                <b style={{ whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', color: exact ? 'var(--good)' : 'var(--ink-700)' }}>{fmtNum(amt, 2)}</b>
              </div>
              <div style={{ fontSize: 11, color: 'var(--ink-500)', marginTop: 2 }}>
                {fmtDate(p.date) || p.date || '—'}
                {exact ? <span style={{ color: 'var(--good)', fontWeight: 700 }}> · ✓ ยอดตรง</span> : <span style={{ color: 'var(--warn)' }}> · ⚠️ ยอดต่าง {fmtNum(Math.abs(amt - target), 2)}</span>}
                {dd != null && dd <= 3 ? <span style={{ color: 'var(--good)' }}> · วันใกล้กัน</span> : (dd != null ? <span style={{ color: 'var(--ink-400)' }}> · ต่าง {dd} วัน</span> : null)}
                {active ? <span style={{ color: 'var(--good)', fontWeight: 700 }}> · เลือกแล้ว</span> : null}
              </div>
            </button>
          );
        })}
      </div>
    </Modal>
  );
}

// ── Modal ใส่รหัสไฟล์ statement ที่เข้ารหัส (เช่น KTB .xls) ──
function BRPasswordModal({ prompt, onClose }) {
  const names = prompt.files || (prompt.file ? [prompt.file.name] : []);   // รองรับทั้งไฟล์เดียว/หลายไฟล์ที่ข้ามจากคิว
  return (
    <Modal open title="🔒 ไฟล์มีรหัสผ่าน — เปิดในเบราว์เซอร์ไม่ได้" maxWidth={440} onClose={onClose}
      footer={<button className="btn btn-ghost" onClick={onClose}>ปิด</button>}>
      <div style={{ fontSize: 12.5, color: 'var(--ink-600)', marginBottom: 12 }}>
        {names.length > 1 ? `${names.length} ไฟล์ถูกข้าม เพราะถูกเข้ารหัสแบบที่เบราว์เซอร์ถอดไม่ได้:` : 'ไฟล์ถูกเข้ารหัสแบบที่เบราว์เซอร์ถอดไม่ได้:'}
        <div style={{ marginTop: 6 }}>{names.map((n, i) => <div key={i}><b style={{ color: 'var(--ink-800)' }}>{n}</b></div>)}</div>
      </div>
      <div style={{ fontSize: 12.5, color: 'var(--ink-700)', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 8, padding: '12px 14px', lineHeight: 2 }}>
        <b>วิธีแก้ (3 ขั้นตอน):</b><br/>
        1. เปิดไฟล์ใน <b>Excel</b> บนเครื่อง แล้วใส่รหัสผ่าน<br/>
        2. <b>Save As</b> เป็น <b>.xlsx</b> หรือ <b>.csv</b> (อย่าใส่รหัสผ่าน)<br/>
        3. อัปโหลดไฟล์ใหม่นั้นแทน
      </div>
    </Modal>
  );
}

// ── โน้ต: แต่ละธนาคารใช้ไฟล์อะไรนำเข้า (พับเก็บได้) ──
const BR_FILE_GUIDE = [
  { bank: 'SCB (ไทยพาณิชย์)', file: '.csv', note: 'ไฟล์รวมหลายบัญชี ระบบกรองให้เอง · มีเลขที่ PV ในไฟล์ → กระทบแม่น' },
  { bank: 'KBANK (กสิกร) · BBL (กรุงเทพ)', file: '.csv', note: 'ภาษาไทย (TIS-620) ระบบอ่านให้อัตโนมัติ' },
  { bank: 'KTB (กรุงไทย)', file: '.xls มีรหัส', note: 'เบราว์เซอร์ถอดรหัสไม่ได้ → เปิดใน Excel แล้ว Save As .xlsx/.csv (ไม่ใส่รหัส) ก่อนนำเข้า' },
  { bank: 'ธนาคารอื่น ๆ', file: '.csv / .xlsx / .xls', note: 'เลือกคอลัมน์เอง (วันที่ / เดบิต-เครดิต / รายละเอียด) ตอนนำเข้า ระบบจำไว้ให้ครั้งต่อไป' },
];
function BRImportHelp() {
  const [open, setOpen] = brState(false);
  return (
    <div className="no-print" style={{ marginTop: 14, marginBottom: 2, paddingTop: 12, borderTop: '1px solid var(--line)' }}>
      <button onClick={() => setOpen(o => !o)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontFamily: 'inherit',
        display: 'flex', alignItems: 'center', gap: 6, fontSize: 12.5, fontWeight: 600, color: 'var(--ink-700)', padding: 0 }}>
        📄 ไฟล์ที่ใช้นำเข้า (แต่ละธนาคารใช้นามสกุลอะไร) <span style={{ color: 'var(--ink-400)' }}>{open ? '▲' : '▼'}</span>
      </button>
      {open && (
        <div style={{ marginTop: 10 }}>
          <table className="tbl" style={{ width: '100%', fontSize: 12 }}>
            <thead><tr>
              <th style={{ textAlign: 'left' }}>ธนาคาร</th>
              <th style={{ textAlign: 'left', whiteSpace: 'nowrap' }}>นามสกุลไฟล์</th>
              <th style={{ textAlign: 'left' }}>หมายเหตุ</th>
            </tr></thead>
            <tbody>
              {BR_FILE_GUIDE.map((r, i) => (
                <tr key={i}>
                  <td style={{ fontWeight: 600 }}>{r.bank}</td>
                  <td style={{ whiteSpace: 'nowrap' }}><code style={{ background: 'var(--ink-100)', padding: '1px 6px', borderRadius: 4 }}>{r.file}</code></td>
                  <td style={{ color: 'var(--ink-600)' }}>{r.note}</td>
                </tr>
              ))}
            </tbody>
          </table>
          <div style={{ marginTop: 10, fontSize: 12, color: 'var(--ink-700)', background: 'color-mix(in oklch, var(--brand-500) 6%, #fff)', border: '1px solid color-mix(in oklch, var(--brand-500) 20%, var(--line))', borderRadius: 8, padding: '9px 12px', lineHeight: 1.7 }}>
            💡 <b>โยนหลายไฟล์พร้อมกันได้</b> — กด “📥 นำเข้า statement” แล้ว <b>ลากไฟล์ทั้งหมดมาวาง</b> ระบบจะ <b>เดาบัญชีจากชื่อไฟล์</b> + โชว์ตารางสรุปว่าไฟล์ไหนเข้าบัญชีไหน กี่รายการ (เปลี่ยนบัญชี/ปรับคอลัมน์/เอาออกได้) แล้วกด <b>นำเข้าทั้งหมดทีเดียว</b> ·
            <b>ไม่จำเป็นต้องครบทุกธนาคาร</b> จะกี่ไฟล์ก็ได้ ·
            นำเข้าทับเดือนเดิม ระบบ <b>คงสถานะที่เคยทำไว้</b> (แมต/โอนระหว่างบัญชี/บันทึกจ่ายจริง) ให้อัตโนมัติ ไม่ต้องทำใหม่
          </div>
        </div>
      )}
    </div>
  );
}

Object.assign(window, { BankReconPage });
