/* =====================================================================
 * page_fleet_card.jsx — บัตรน้ำมัน (Fleet Card)
 * =====================================================================
 * งานจริงที่หน้านี้แทน: ทุกเดือนธนาคาร (KBank fleet card) ส่งไฟล์เรียกเก็บ
 *   "YYYYMMDD_REPORT001_<เลขลูกค้า>_CSV.csv" (TIS-620 · คั่นด้วย "|") มาให้ →
 *   เอามาเทียบว่ารายการเติมน้ำมันแต่ละครั้ง "ตั้ง OF ใน Mango ครบแล้วหรือยัง"
 *
 *   ธนาคาร (fleetCardTxns)        ─┐
 *                                  ├─► กระทบรายรายการ → ตั้งแล้ว / ยังไม่ตั้ง / ตั้งเกิน
 *   Mango export (fleetCardMango) ─┘
 *
 * การจับคู่คำนวณสดทุกครั้ง (ไม่เก็บผลลัพธ์) — เก็บเฉพาะที่ผู้ใช้จับคู่เอง/ยืนยัน
 *   ไว้ที่ fleetCardTxns.mangoId + matchMode และสถานะ "ไม่ต้องตั้ง" ที่ .status
 *
 * Globals ที่ใช้ร่วมกับ app shell: React, Icon, Modal, WTPData, WTPAuth, XLSX,
 *   fmtMoney, fmtNum, fmtInt, exportRowsToExcel
 * ทุกอย่างอยู่ใน IIFE — export เฉพาะ window.FleetCardPage
 * ===================================================================== */
(function () {
  'use strict';
  const { useState, useEffect, useMemo, useRef } = React;

  /* ชุดบัตร — คนละชุดบัตร คนละไฟล์เรียกเก็บจากธนาคาร และตั้ง OF แยกกัน
   *   ข้อมูลทุกอย่าง (บิล / OF / การจับคู่) แยกขาดตาม cardProgram ไม่ปนกันข้ามแท็บ
   *   แถวเก่าที่ยังไม่มีค่า (นำเข้าก่อนมีแท็บนี้) ถือเป็น 'fleet' */
  const FC_PROGRAMS = [
    { key: 'fleet', label: 'FLEET CARD', short: 'Fleet Card', icon: '⛽',
      sub: 'บัตรน้ำมันประจำรถ — ไฟล์เรียกเก็บ REPORT001' },
    { key: 'exec', label: 'EXECUTIVE CARD', short: 'Executive Card', icon: '💳',
      sub: 'บัตรผู้บริหาร — ไฟล์เรียกเก็บคนละชุด' },
  ];
  const fcProgOf = (r) => (r && r.cardProgram) || 'fleet';
  const fcProgInfo = (key) => FC_PROGRAMS.find(p => p.key === key) || FC_PROGRAMS[0];

  const FC_TXN = 'fleetCardTxns';    // รายการเติมจากไฟล์ธนาคาร
  const FC_MNG = 'fleetCardMango';   // รายการที่ตั้ง OF ใน Mango

  const FC_MONTHS_TH = ['ม.ค.', 'ก.พ.', 'มี.ค.', 'เม.ย.', 'พ.ค.', 'มิ.ย.',
                        'ก.ค.', 'ส.ค.', 'ก.ย.', 'ต.ค.', 'พ.ย.', 'ธ.ค.'];

  /* ── helpers เล็กๆ ─────────────────────────────────────────────────── */
  const fcNum = (v) => {
    if (v == null || v === '') return 0;
    const n = Number(String(v).replace(/[,\s฿]/g, ''));
    return isFinite(n) ? n : 0;
  };
  const fcTrim = (v) => String(v == null ? '' : v).trim();
  const fcDigits = (v) => String(v == null ? '' : v).replace(/\D+/g, '');
  // normalize ข้อความไทย/อังกฤษ สำหรับเทียบชื่อปั๊ม/ชื่อคน (ตัดคำนำหน้านิติบุคคล + อักขระคั่น)
  //   ไฟล์ธนาคารเขียนย่อ ("บ.ปตท.บริหารธุรกิจค้าปลีก จก.") ส่วน Mango เขียนเต็ม
  //   ("บริษัท ปตท.บริหารธุรกิจค้าปลีก จำกัด") → ตัดคำนำหน้า/ท้ายทิ้งให้เหลือชื่อจริงเท่ากัน
  const fcTxtNorm = (s) => String(s == null ? '' : s)
    .replace(/\(มหาชน\)/g, '')
    .replace(/บริษัท|ห้างหุ้นส่วนจำกัด|ห้างหุ้นส่วนสามัญ|บมจ\.|บจก\.|บจ\.|หจก\.|หสน\.|บ\.|จำกัด|มหาชน|สาขา|คุณ|นางสาว|นาง|นาย/g, '')
    .replace(/จก\.?\s*$/, '')
    .replace(/[\s\-_.,()#/\\|'"]+/g, '')
    .toUpperCase();
  // ชื่อ 2 แหล่ง "น่าจะที่เดียวกัน" ไหม — เทียบแบบ normalize แล้วดูการครอบ/ขึ้นต้นตรงกัน
  function fcNameLike(a, b) {
    const x = fcTxtNorm(a), y = fcTxtNorm(b);
    if (x.length < 5 || y.length < 5) return false;
    if (x === y) return true;
    if (x.indexOf(y) >= 0 || y.indexOf(x) >= 0) return true;
    const n = Math.min(x.length, y.length, 10);
    return n >= 7 && x.slice(0, n) === y.slice(0, n);
  }
  // ทะเบียนรถ: "6ขฬ 6700" / "ขง 1479" / "VIP 2" → คีย์เทียบ (ตัดช่องว่าง)
  const fcPlateNorm = (p) => String(p == null ? '' : p).replace(/[\s\-.]/g, '').toUpperCase();
  // ดึงทะเบียนออกจากข้อความ (itemname ของรายการ / หมายเหตุหัวใบ OF) — ไม่มีก็คืนค่าว่าง
  function fcExtractPlate(text) {
    const s = String(text == null ? '' : text);
    const RE_AFTER = /ทะเบียน(?:รถ)?(?:ยนต์)?[\s:]*(\d{0,2}\s?[ก-ฮ]{1,2}\s?\d{1,4})/;
    const RE_PLAIN = /(\d{1,2}\s?[ก-ฮ]{1,2}\s?\d{2,4}|[ก-ฮ]{2}\s?\d{2,4})/;
    const RE_VIP = /(VIP\s*\d{1,2})/i;
    const m = s.match(RE_AFTER) || s.match(RE_VIP) || s.match(RE_PLAIN);
    return m ? m[1].replace(/\s+/g, ' ').trim() : '';
  }

  const fcYm = (iso) => String(iso || '').slice(0, 7);
  const fcFmtMonth = (ym) => {
    const p = String(ym || '').split('-');
    const y = Number(p[0]), m = Number(p[1]);
    if (!y || !m) return ym || '—';
    return FC_MONTHS_TH[m - 1] + ' ' + (y + 543);     // แสดงเป็น พ.ศ.
  };
  const fcDateDiff = (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));
  };
  // hash สั้นๆ สำหรับสร้าง id คงที่ (นำเข้าซ้ำแล้วทับแถวเดิม ไม่เพิ่มซ้ำ)
  function fcHash(s) {
    let h = 5381;
    const str = String(s || '');
    for (let i = 0; i < str.length; i++) h = ((h * 33) ^ str.charCodeAt(i)) >>> 0;
    return h.toString(36);
  }

  // "18-08-2026" / "18/08/2569" / Date / Excel serial → ISO 'YYYY-MM-DD'
  function fcISO(v) {
    if (v == null || v === '') return '';
    if (v instanceof Date && !isNaN(v.getTime())) {
      return new Date(v.getTime() - v.getTimezoneOffset() * 60000).toISOString().slice(0, 10);
    }
    const s = fcTrim(v);
    if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
    const m = s.match(/(\d{1,2})[-/.](\d{1,2})[-/.](\d{2,4})/);
    if (m) {
      let y = Number(m[3].length === 2 ? '25' + m[3] : m[3]);
      if (y > 2400) y -= 543;                                   // พ.ศ. → ค.ศ.
      if (y < 100) y += 2000;
      return y + '-' + String(Number(m[2])).padStart(2, '0') + '-' + String(Number(m[1])).padStart(2, '0');
    }
    if (/^\d{5}$/.test(s)) {                                    // Excel serial
      const d = new Date(Date.UTC(1899, 11, 30) + Number(s) * 86400000);
      return d.toISOString().slice(0, 10);
    }
    return '';
  }
  // "020769" (DDMMYY พ.ศ. — รูปแบบที่ Mango ใช้ขึ้นต้น itemname / pre_event) → ISO
  function fcISOfromDMY6(v) {
    const s = fcDigits(v);
    if (s.length !== 6 && s.length !== 5) return '';
    const p = s.padStart(6, '0');
    const dd = Number(p.slice(0, 2)), mm = Number(p.slice(2, 4));
    const y = 2500 + Number(p.slice(4, 6)) - 543;
    if (!dd || !mm || dd > 31 || mm > 12) return '';
    return y + '-' + String(mm).padStart(2, '0') + '-' + String(dd).padStart(2, '0');
  }

  // decode ไฟล์ข้อความ: ลอง UTF-8 ก่อน ถ้าไม่ผ่าน fallback windows-874 (TIS-620 ของ KBank)
  function fcDecodeText(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); }
    }
  }

  /* ── 1) Parser ไฟล์เรียกเก็บจากธนาคาร ────────────────────────────────
   *   หัวคอลัมน์จริง: POST_DATE|TRN_DATE|TRN_TIME|COMPANY_NAME|CUSTOMER_ID|
   *   CREDIT_LINE|DRIVER_NAME|CARD_NO|PLATE_NO|DEPARTMENT|COSTCENTER|MER_NO|
   *   TAX_ID|MERCHANT_NAME_THAI|MERCHANT_LOCATION|MERCHANT_ZIP_CODE|INVOICE_NO|
   *   PRODUCT_NAME|QTY_LITRE|QTY_KG|EXCLUDE_VAT_AMOUNT|VAT_AMOUNT|AMT_BAHT|
   *   PRICE_RATE|ODOMETER|DISTANCE|KM_LITRE|…|BRANCH_NUMBER|…_PP20|…
   *   (คั่นด้วย "|" + pad ช่องว่าง — เผื่อไฟล์รุ่นใหม่ใช้ , หรือ tab ก็รองรับ) */
  function fcSplitDelim(text) {
    const firstLine = (text.split(/\r?\n/)[0] || '');
    const pipes = (firstLine.match(/\|/g) || []).length;
    const tabs = (firstLine.match(/\t/g) || []).length;
    const commas = (firstLine.match(/,/g) || []).length;
    if (pipes >= 5) return '|';
    if (tabs >= 5) return '\t';
    if (commas >= 5) return ',';
    return '|';
  }
  function fcParseBankText(text, fileName) {
    const out = { rows: [], ym: '', error: '', customerId: '' };
    const lines = String(text || '').split(/\r?\n/).filter(l => l.trim() !== '');
    if (lines.length < 2) { out.error = 'ไฟล์ว่าง หรือไม่มีรายการ'; return out; }
    const D = fcSplitDelim(text);
    const head = lines[0].split(D).map(h => fcTrim(h).toUpperCase());
    const col = (...names) => {
      for (const n of names) { const i = head.indexOf(n); if (i >= 0) return i; }
      for (const n of names) { const i = head.findIndex(h => h.indexOf(n) >= 0); if (i >= 0) return i; }
      return -1;
    };
    const C = {
      post: col('POST_DATE'), trn: col('TRN_DATE'), time: col('TRN_TIME'),
      cust: col('CUSTOMER_ID'), driver: col('DRIVER_NAME'), card: col('CARD_NO'),
      plate: col('PLATE_NO'), dept: col('DEPARTMENT'), cc: col('COSTCENTER'),
      merNo: col('MER_NO'), taxId: col('TAX_ID'), merName: col('MERCHANT_NAME_THAI'),
      merLoc: col('MERCHANT_LOCATION'), inv: col('INVOICE_NO'), prod: col('PRODUCT_NAME'),
      litre: col('QTY_LITRE'), exVat: col('EXCLUDE_VAT_AMOUNT'), vat: col('VAT_AMOUNT'),
      amt: col('AMT_BAHT'), rate: col('PRICE_RATE'), odo: col('ODOMETER'),
      dist: col('DISTANCE'), kml: col('KM_LITRE'), branch: col('BRANCH_NUMBER'),
      pp20: col('MERCHANT_NAME_VAT_CERTIFICATE_PP20'), pp20a: col('ADDRESS_VAT_CERTIFICATE_PP20'),
    };
    if (C.amt < 0 || C.inv < 0) {
      out.error = 'ไม่พบคอลัมน์ AMT_BAHT / INVOICE_NO — ตรวจว่าเป็นไฟล์ REPORT001 ของธนาคาร';
      return out;
    }
    const g = (f, i) => (i >= 0 && i < f.length ? fcTrim(f[i]) : '');
    const ymCount = {};
    lines.slice(1).forEach((line, idx) => {
      const f = line.split(D);
      const amount = fcNum(g(f, C.amt));
      const invoiceNo = g(f, C.inv);
      if (!amount && !invoiceNo) return;                       // แถวท้ายไฟล์/แถวว่าง
      const postDate = fcISO(g(f, C.post));
      const trnDate = fcISO(g(f, C.trn)) || postDate;
      const ym = fcYm(postDate || trnDate);
      if (ym) ymCount[ym] = (ymCount[ym] || 0) + 1;
      const cc = g(f, C.cc), dept = g(f, C.dept);
      out.rows.push({
        _seq: idx,
        postDate, trnDate, trnTime: g(f, C.time),
        customerId: g(f, C.cust),
        cardNo: g(f, C.card), plateNo: g(f, C.plate),
        driverName: g(f, C.driver), department: dept, costCenter: cc,
        deptCode: (cc || dept || '').slice(0, 20),
        merchantNo: g(f, C.merNo), merchantTaxId: g(f, C.taxId),
        merchantName: g(f, C.merName), merchantLocation: g(f, C.merLoc),
        branchNo: g(f, C.branch), pp20Name: g(f, C.pp20), pp20Address: g(f, C.pp20a),
        invoiceNo, productName: g(f, C.prod),
        qtyLitre: fcNum(g(f, C.litre)), exVatAmount: fcNum(g(f, C.exVat)),
        vatAmount: fcNum(g(f, C.vat)), amount,
        priceRate: fcNum(g(f, C.rate)), odometer: fcNum(g(f, C.odo)),
        distance: fcNum(g(f, C.dist)), kmPerLitre: fcNum(g(f, C.kml)),
      });
      if (!out.customerId) out.customerId = g(f, C.cust);
    });
    // รอบบิล = เดือนของ POST_DATE ที่พบมากที่สุด (เผื่อคาบเกี่ยวต้นเดือน) — ไม่มีก็เดาจากชื่อไฟล์
    let best = '', bestN = 0;
    Object.keys(ymCount).forEach(k => { if (ymCount[k] > bestN) { best = k; bestN = ymCount[k]; } });
    if (!best) {
      const m = String(fileName || '').match(/(20\d{2})(\d{2})(\d{2})/);
      if (m) best = m[1] + '-' + m[2];
    }
    out.ym = best;
    if (!out.rows.length) out.error = 'อ่านได้ 0 รายการ — ตรวจรูปแบบไฟล์';
    return out;
  }

  /* ── 1b) Parser statement บัตรเครดิต (EXECUTIVE CARD) ────────────────
   *   ไฟล์ธนาคารของบัตรผู้บริหารเป็นคนละแบบกับ Fleet Card — ไม่มีลิตร/ปั๊ม/ทะเบียน
   *   หัวไฟล์ 5 บรรทัด: Card Number / Card Holder Name / Card Nickname /
   *     Statement Date / Due Date
   *   ตาราง 4 คอลัมน์: Effective Date | Posting Date | Transfer Name | Transfer Amount
   *   แถวที่ "ไม่ใช่ค่าใช้จ่ายรอบนี้" ต้องแยกออก ไม่งั้นยอดกระทบเพี้ยน:
   *     PREVIOUS BALANCE (ยอดยกมา) · AUTOPAYMENT (ตัดชำระ) · TR.FROM/เงินคืน (ยอดติดลบ) */
  function fcExecRowType(name, amount) {
    const s = fcTrim(name).toUpperCase();
    if (/PREVIOUS\s*BALANCE|BALANCE\s*B\/?F|ยอดยกมา/.test(s)) return 'balance';
    if (/AUTOPAYMENT|PAYMENT\s*-\s*THANK|THANK\s*YOU|ชำระเงิน|รับชำระ/.test(s)) return 'payment';
    if (/^TR\.?FROM|REFUND|REVERSAL|CASH\s*BACK|โอนเข้า|เงินคืน/.test(s) || fcNum(amount) < 0) return 'credit';
    return 'expense';
  }
  function fcParseExecStatement(aoa, fileName) {
    const out = { rows: [], ym: '', error: '', cardNo: '', cardHolder: '', statementDate: '', dueDate: '' };
    if (!Array.isArray(aoa) || !aoa.length) { out.error = 'ชีตว่าง'; return out; }
    const cellStr = (r, c) => fcTrim((aoa[r] || [])[c]);
    // ── หัวไฟล์: อ่านทุกช่องใน 12 แถวแรก แล้วจับคู่ตาม label ──
    for (let i = 0; i < Math.min(aoa.length, 12); i++) {
      (aoa[i] || []).forEach(cell => {
        const s = fcTrim(cell);
        let m;
        if ((m = s.match(/card\s*number\s*[:：]\s*(.+)$/i))) out.cardNo = fcTrim(m[1]);
        else if ((m = s.match(/card\s*holder\s*name\s*[:：]\s*(.+)$/i))) out.cardHolder = fcTrim(m[1]);
        else if ((m = s.match(/statement\s*date\s*[:：]\s*(.+)$/i))) out.statementDate = fcISO(m[1]);
        else if ((m = s.match(/due\s*date\s*[:：]\s*(.+)$/i))) out.dueDate = fcISO(m[1]);
      });
    }
    // ── หาแถวหัวตาราง ──
    let hr = -1;
    for (let i = 0; i < Math.min(aoa.length, 20); i++) {
      const j = (aoa[i] || []).map(c => fcTrim(c).toLowerCase()).join('|');
      if (/effective\s*date|วันที่ทำรายการ/.test(j) && /amount|จำนวนเงิน/.test(j)) { hr = i; break; }
    }
    if (hr < 0) {
      out.error = 'ไม่พบหัวตาราง (Effective Date / Transfer Amount) — ตรวจว่าเป็นไฟล์ statement บัตรเครดิต';
      return out;
    }
    const H = (aoa[hr] || []).map(c => fcTrim(c).toLowerCase());
    const col = (re, fallback) => { const i = H.findIndex(h => re.test(h)); return i >= 0 ? i : fallback; };
    const cEff = col(/effective\s*date|วันที่ทำรายการ/, 0);
    const cPost = col(/posting\s*date|วันที่บันทึก|วันที่ลงบัญชี/, 1);
    const cName = col(/transfer\s*name|description|รายละเอียด|ร้านค้า/, 2);
    const cAmt = col(/transfer\s*amount|amount|จำนวนเงิน/, 3);
    const ymCount = {};
    for (let i = hr + 1; i < aoa.length; i++) {
      const row = aoa[i] || [];
      const name = fcTrim(row[cName]);
      const rawAmt = fcTrim(row[cAmt]);
      if (!name && !rawAmt) continue;
      const amount = fcNum(rawAmt);
      if (!name || (!amount && !/balance/i.test(name))) continue;
      const trnDate = fcISO(row[cEff]);
      const postDate = fcISO(row[cPost]) || trnDate;
      const txnType = fcExecRowType(name, amount);
      if (txnType === 'expense' && postDate) {
        const k = fcYm(postDate);
        if (k) ymCount[k] = (ymCount[k] || 0) + 1;
      }
      out.rows.push({
        _seq: i,
        trnDate, postDate, trnTime: '',
        cardNo: out.cardNo, plateNo: '', driverName: out.cardHolder,
        customerId: fcDigits(out.cardNo).slice(-4),
        merchantName: name, pp20Name: '', merchantLocation: '', merchantNo: '', merchantTaxId: '',
        branchNo: '', pp20Address: '', department: '', costCenter: '', deptCode: '',
        invoiceNo: '', productName: '',
        qtyLitre: 0, exVatAmount: 0, vatAmount: 0, priceRate: 0, odometer: 0, distance: 0, kmPerLitre: 0,
        amount, txnType,
        statementDate: out.statementDate, dueDate: out.dueDate,
      });
    }
    // รอบ = เดือนของ Statement Date (ถ้าไม่มีใช้เดือนที่ลงบัญชีบ่อยสุด / ชื่อไฟล์)
    let best = '', bestN = 0;
    Object.keys(ymCount).forEach(k => { if (ymCount[k] > bestN) { best = k; bestN = ymCount[k]; } });
    out.ym = fcYm(out.statementDate) || best;
    if (!out.ym) {
      const m = String(fileName || '').match(/(0[1-9]|1[0-2])[._-]?(20\d{2})/);
      if (m) out.ym = m[2] + '-' + m[1];
    }
    if (!out.rows.length) out.error = 'อ่านได้ 0 รายการ — ตรวจรูปแบบไฟล์';
    return out;
  }

  /* ── 2) Parser ไฟล์ export จาก Mango (xls / xlsx / csv) ──────────────
   *   ใช้ 2 รายงานคู่กัน (ตามวิธีเรียกรายงาน OF ที่ใช้อยู่):
   *     • "OF 9.3" (v_exp_rpt344) = รายละเอียดรายบรรทัดในใบ OF
   *         amt = ยอดต่อครั้ง (ตรงกับบิลน้ำมัน) · itemname มีฝ่าย/ผู้ถือบัตร/ทะเบียน
   *         (ไม่ครบทุกแถว แล้วแต่คนตั้ง) · remark = ชื่อผู้ขาย
   *         ★ ไม่มีวันที่เติมและไม่มีเลขใบกำกับ → จับคู่ด้วยยอด + ปั๊ม + ทะเบียน/ชื่อ
   *     • "OF ยอดรวม" (v_exp_rpt130) = หัวใบ OF
   *         plno = เลข PV ที่ดึงไปตัดจ่าย · pay_status · paydate · ยอดตั้ง/ยอดจ่าย
   *         · remark หัวใบ (มักมีทะเบียนรถ + "ประจำเดือน …")
   *   เชื่อม 2 ไฟล์ด้วย docno → เก็บเป็นแถวเดียว: รายการใน OF + สถานะตัดจ่ายของใบนั้น
   *   อัปโหลดไฟล์เดียวก็ยังใช้ได้ (มีแต่ 9.3 = ไม่รู้สถานะจ่าย · มีแต่ยอดรวม = ได้ระดับใบ) */
  const FC_MNG_KEYS = /(expens|amt|amount|จำนวนเงิน|itemname|รายละเอียด|description|docno|เลขที่เอกสาร|vchno|remark|vendor|ผู้ขาย|invoice|ใบกำกับ|othnetamt|paynetamt|plno|pay_status)/i;

  // เลข OF → รอบเดือน: OF<YY><MM><running> · YY เป็นปี ค.ศ. 2 หลัก (OF260700180 → 2026-07)
  //   เผื่อระบบเปลี่ยนไปออกเลขเป็น พ.ศ. (OF69xx…) ด้วย — ถ้าปีที่ได้ล้ำอนาคตเกินไปถือว่าเป็น พ.ศ.
  function fcYmFromDocNo(docNo) {
    const m = String(docNo || '').match(/^[A-Za-z]{2}(\d{2})(\d{2})\d+$/);
    if (!m) return '';
    const mm = Number(m[2]);
    if (!mm || mm > 12) return '';
    let y = 2000 + Number(m[1]);
    if (y > new Date().getFullYear() + 5) y = 2500 + Number(m[1]) - 543;
    return y + '-' + m[2];
  }

  function fcGuessHeaderRow(aoa) {
    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 => FC_MNG_KEYS.test(String(c))).length;
      if (score > bestScore) { bestScore = score; best = i; }
    }
    return best >= 0 ? best : 0;
  }
  // ไฟล์นี้เป็นรายงานแบบไหน — ดูจากหัวคอลัมน์ (ไม่ต้องให้ผู้ใช้เลือกเอง)
  // ไฟล์ OF บอกชุดบัตรมาในตัว: acctname = '…เพื่อรับชำระบัตร FLEET CARD' / '…EXECUTIVE CARD'
  function fcGuessProgram(rows) {
    let fleet = 0, exec = 0;
    (rows || []).forEach(r => {
      const t = (fcTrim(r.acctName) + ' ' + fcTrim(r.acctNo)).toUpperCase();
      if (/EXECUTIVE/.test(t)) exec++;
      else if (/FLEET/.test(t)) fleet++;
    });
    if (exec && !fleet) return 'exec';
    if (fleet && !exec) return 'fleet';
    return '';                                   // บอกไม่ได้ / ปนกัน → ไม่เตือน
  }
  function fcMangoKind(headers) {
    const H = headers.map(h => fcTrim(h).toLowerCase());
    const has = (re) => H.some(h => re.test(h));
    if (has(/^amt$/) && has(/^itemname$/)) return 'detail';                 // OF 9.3
    if (has(/^plno$/) || has(/^pay_status$/) || has(/^othnetamt$/)) return 'header'; // OF ยอดรวม
    if (has(/^amt$|^amount$|จำนวนเงิน|ยอดเงิน/)) return 'detail';           // ไฟล์อื่นที่มียอดต่อรายการ
    return '';
  }
  function fcMapMangoCols(headers) {
    const H = headers.map(h => fcTrim(h).toLowerCase());
    const find = (re) => { for (let i = 0; i < H.length; i++) if (re.test(H[i])) return i; return -1; };
    return {
      expenseCode: find(/^expens_code|expense code|รหัสค่าใช้จ่าย/),
      expenseName: find(/^expens_name|expense name|ชื่อค่าใช้จ่าย/),
      empCode: find(/^empcode|รหัสพนักงาน/),
      empName: find(/empfullname|ชื่อพนักงาน|ผู้เบิก/),
      amount: find(/^amt$|^amount$|จำนวนเงิน|ยอดเงิน|^debit$/),
      itemName: find(/^itemname|รายละเอียด|description|ชื่อรายการ/),
      preEvent: find(/^pre_event$/),
      plateTail: find(/^pre_event2$|ทะเบียน/),
      vendor: find(/^remark$|vendor|ผู้ขาย|ผู้จำหน่าย|^company$|ร้านค้า/),
      invoiceNo: find(/invoice|ใบกำกับ|เลขที่ใบเสร็จ/),
      docNo: find(/^docno|เลขที่เอกสาร|doc no/),
      refDate: find(/^refdate_s$|^refdate$|วันที่เอกสาร|inv\.? date/),
      vchNo: find(/^vchno|voucher no/),
      vchDate: find(/^vchdate_s$|^vchdate$|voucher date/),
      deptCode: find(/^dpt_code|dept code|รหัสแผนก|cost code/),
      deptName: find(/^dpt_name|dept name|ชื่อแผนก|^department$/),
      itemNo: find(/^itemno$|ลำดับ/),
      acctName: find(/^acctname$|ชื่อบัญชี/),
      acctNo: find(/^acct_no$/),
      // ── หัวใบ OF (v_exp_rpt130) ──
      docAmount: find(/^othnetamt$/),
      docAppAmount: find(/^appnetamt$/),
      docPaidAmount: find(/^paynetamt$/),
      appStatus: find(/^app_status$|^docapp$/),
      payStatus: find(/^pay_status$/),
      pvNo: find(/^plno$|^pv_?no$|ใบสำคัญจ่าย/),
      payDate: find(/^paydate_s$|^paydate$|วันที่จ่าย/),
    };
  }
  // แถวรายการใน OF (รายงาน 9.3)
  function fcParseMangoDetail(aoa, hr, M) {
    const rows = [];
    const g = (row, i) => (i >= 0 && i < row.length ? row[i] : '');
    for (let i = hr + 1; i < aoa.length; i++) {
      const row = aoa[i] || [];
      const amount = fcNum(g(row, M.amount));
      const itemName = fcTrim(g(row, M.itemName));
      const vendor = fcTrim(g(row, M.vendor));
      const docNo = fcTrim(g(row, M.docNo));
      if (!amount || !docNo) continue;                          // แถวรวม/แถวว่างท้ายรายงาน
      // วันที่เติม: รายงาน 9.3 ไม่มี — เผื่อไฟล์รูปแบบอื่นที่ใส่ DDMMYY มาใน pre_event/itemname
      const eventDate = fcISOfromDMY6(g(row, M.preEvent))
        || fcISOfromDMY6((itemName.match(/^\s*(\d{5,6})\s*\//) || [])[1]);
      const plateNo = fcExtractPlate(itemName);
      rows.push({
        _seq: i,
        expenseCode: fcTrim(g(row, M.expenseCode)), expenseName: fcTrim(g(row, M.expenseName)),
        empCode: fcTrim(g(row, M.empCode)), empName: fcTrim(g(row, M.empName)),
        amount, itemName, eventDate, vendor,
        plateNo, plateTail: fcDigits(g(row, M.plateTail)).slice(-4) || fcDigits(plateNo).slice(-4),
        invoiceNo: fcDigits(g(row, M.invoiceNo)),
        docNo, refDate: fcISO(g(row, M.refDate)),
        vchNo: fcTrim(g(row, M.vchNo)), vchDate: fcISO(g(row, M.vchDate)),
        deptCode: fcTrim(g(row, M.deptCode)), deptName: fcTrim(g(row, M.deptName)),
        itemNo: fcTrim(g(row, M.itemNo)),
        acctName: fcTrim(g(row, M.acctName)), acctNo: fcTrim(g(row, M.acctNo)),
        ym: fcYmFromDocNo(docNo) || fcYm(fcISO(g(row, M.vchDate)) || fcISO(g(row, M.refDate))),
      });
    }
    return rows;
  }
  // แถวหัวใบ OF (รายงานยอดรวม) → map docNo → ข้อมูลใบ (PV / สถานะจ่าย / ยอด / หมายเหตุ)
  function fcParseMangoHeader(aoa, hr, M) {
    const rows = [];
    const g = (row, i) => (i >= 0 && i < row.length ? row[i] : '');
    for (let i = hr + 1; i < aoa.length; i++) {
      const row = aoa[i] || [];
      const docNo = fcTrim(g(row, M.docNo));
      if (!docNo) continue;
      const docRemark = fcTrim(g(row, M.vendor));            // remark ของหัวใบ (คนละความหมายกับ vendor)
      rows.push({
        docNo,
        docRemark,
        docAmount: fcNum(g(row, M.docAmount)),
        docPaidAmount: fcNum(g(row, M.docPaidAmount)),
        appStatus: fcTrim(g(row, M.appStatus)),
        payStatus: fcTrim(g(row, M.payStatus)),
        pvNo: fcTrim(g(row, M.pvNo)),
        payDate: fcISO(g(row, M.payDate)),
        deptCode: fcTrim(g(row, M.deptCode)), deptName: fcTrim(g(row, M.deptName)),
        vchNo: fcTrim(g(row, M.vchNo)), vchDate: fcISO(g(row, M.vchDate)),
        refDate: fcISO(g(row, M.refDate)),
        plateNo: fcExtractPlate(docRemark),
        acctName: fcTrim(g(row, M.acctName)), acctNo: fcTrim(g(row, M.acctNo)),
        ym: fcYmFromDocNo(docNo) || fcYm(fcISO(g(row, M.vchDate))),
      });
    }
    return rows;
  }
  function fcParseMangoAoa(aoa) {
    const out = { kind: '', rows: [], error: '' };
    if (!Array.isArray(aoa) || !aoa.length) { out.error = 'ชีตว่าง'; return out; }
    const hr = fcGuessHeaderRow(aoa);
    const headers = (aoa[hr] || []).map(c => fcTrim(c));
    const M = fcMapMangoCols(headers);
    out.kind = fcMangoKind(headers);
    if (!out.kind) { out.error = 'ไม่รู้จักรูปแบบไฟล์ — ต้องเป็นรายงาน OF 9.3 หรือ OF ยอดรวม'; return out; }
    if (out.kind === 'detail' && M.amount < 0) { out.error = 'ไม่พบคอลัมน์จำนวนเงิน (amt)'; return out; }
    if (M.docNo < 0) { out.error = 'ไม่พบคอลัมน์เลขที่เอกสาร (docno)'; return out; }
    out.rows = out.kind === 'detail' ? fcParseMangoDetail(aoa, hr, M) : fcParseMangoHeader(aoa, hr, M);
    if (!out.rows.length) out.error = 'อ่านได้ 0 รายการ — ตรวจว่าเลือกชีตถูกต้อง';
    return out;
  }
  /* รวมรายการ (detail) กับหัวใบ (header) เป็นแถวเดียวพร้อมเก็บ
   *   - มี detail  → ใช้ detail เป็นตัวตั้ง แล้วเติมข้อมูล PV/สถานะจ่ายจากหัวใบที่ docNo ตรงกัน
   *   - มีแต่หัวใบ → สร้างแถวละ 1 ใบ (ยอด = ยอดตั้งทั้งใบ) เพื่อให้ยังเทียบยอดรวมได้ */
  function fcMergeMango(details, headers) {
    const hByDoc = {};
    (headers || []).forEach(h => { hByDoc[h.docNo] = h; });
    if (details && details.length) {
      return details.map(d => {
        const h = hByDoc[d.docNo] || {};
        return Object.assign({}, d, {
          docRemark: h.docRemark || '',
          docAmount: h.docAmount != null ? h.docAmount : null,
          docPaidAmount: h.docPaidAmount != null ? h.docPaidAmount : null,
          appStatus: h.appStatus || '', payStatus: h.payStatus || '',
          pvNo: h.pvNo || '', payDate: h.payDate || '',
          // ทะเบียน: เอาจาก itemname ก่อน ถ้าไม่มีใช้ของหมายเหตุหัวใบ
          plateNo: d.plateNo || h.plateNo || '',
          plateTail: d.plateTail || fcDigits(h.plateNo || '').slice(-4),
          deptCode: d.deptCode || h.deptCode || '',
          vchNo: d.vchNo || h.vchNo || '', vchDate: d.vchDate || h.vchDate || '',
          refDate: d.refDate || h.refDate || '',
          ym: d.ym || h.ym || '',
        });
      });
    }
    return (headers || []).map((h, i) => Object.assign({}, h, {
      _seq: i, amount: h.docAmount, itemName: h.docRemark, vendor: '', eventDate: '',
      expenseCode: '', expenseName: '', empCode: '', empName: '', invoiceNo: '', itemNo: '',
      plateTail: fcDigits(h.plateNo || '').slice(-4), headerOnly: true,
    }));
  }
  /* รวมแถวจากหลายไฟล์โดยไม่นับซ้ำ — ลากไฟล์เดิมเข้ามา 2 ครั้ง หรือ export ช่วงวันที่คาบเกี่ยวกัน
   *   แถวเดียวกันที่อยู่คนละไฟล์ = นับครั้งเดียว · แต่แถวหน้าตาเหมือนกันที่ซ้ำ "ในไฟล์เดียวกัน"
   *   ถือเป็นของจริง (ตั้ง 2 บรรทัดยอดเท่ากันในใบเดียว) → เก็บตามจำนวนครั้งที่มากที่สุดในไฟล์ใดไฟล์หนึ่ง */
  function fcUnionRows(lists, keyOf) {
    const have = {}, out = [];
    (lists || []).forEach(rows => {
      const cnt = {};
      (rows || []).forEach(r => {
        const k = keyOf(r);
        cnt[k] = (cnt[k] || 0) + 1;
        if (cnt[k] > (have[k] || 0)) { have[k] = cnt[k]; out.push(r); }
      });
    });
    return out;
  }
  const fcMangoRowKey = (r) => [r.docNo, r.itemNo, r.amount, r.vendor, r.itemName, r.eventDate].join('~');
  // รอบ "ตั้งต้น" ของใบ OF (ตามเลข OF ตอนนำเข้า) — ฝังอยู่ใน id · ไม่เปลี่ยนแม้ผู้ใช้ย้ายรอบไปแล้ว
  function fcMangoHomeYm(r) {
    const seg = String((r && r.id) || '').split('|')[1] || '';
    return /^\d{4}-\d{2}$/.test(seg) ? seg : ((r && r.ym) || '');
  }
  // ไฟล์ → { sheetNames, sheets } (csv decode เองกัน mojibake ไทย)
  function fcReadWorkbook(file) {
    return file.arrayBuffer().then(buf => {
      const name = (file.name || '').toLowerCase();
      let wb;
      if (/\.(csv|txt)$/.test(name)) wb = XLSX.read(fcDecodeText(buf), { type: 'string', raw: true });
      else wb = XLSX.read(buf, { type: 'array', cellDates: true });
      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 };
    });
  }
  // เลือกชีตที่ "น่าจะใช่" ที่สุด — นับ keyword ในหัวตาราง + จำนวนแถว + ชื่อชีต
  function fcPickMangoSheet(sheetNames, sheets) {
    let best = sheetNames[0], bestScore = -1;
    sheetNames.forEach(n => {
      const aoa = sheets[n] || [];
      if (!aoa.length) return;
      const hr = fcGuessHeaderRow(aoa);
      const hits = (aoa[hr] || []).filter(c => FC_MNG_KEYS.test(String(c))).length;
      const score = hits * 10 + Math.min(aoa.length, 200) / 100 + (/mango|^of|สรุป/i.test(n) ? 5 : 0);
      if (score > bestScore) { bestScore = score; best = n; }
    });
    return best;
  }

  /* ── ตารางผู้ถือบัตร → แผนก (ชีต "Dep." ในไฟล์ DATA ของฝ่ายการเงิน) ──
   *   ไฟล์ธนาคารเว้นช่อง DEPARTMENT/COSTCENTER ว่างไว้เยอะ (โดยเฉพาะบัตร VIP)
   *   ตารางนี้ช่วยเติมให้ → หน้าสรุปรายแผนกถึงจะใช้ได้จริง
   *   เก็บผ่าน manualOverrides (sync ทั้งทีม) คีย์ 'fcDept.<ชื่อ/ทะเบียน ที่ normalize แล้ว>' */
  const FC_DEPT_PREFIX = 'fcDept.';
  // id ของใบ OF ที่ผู้ใช้ลบทิ้งไปแล้ว — กันไม่ให้กลับมาตอนนำเข้าไฟล์เดิมซ้ำ (sync ทั้งทีม)
  const FC_YM_PREFIX = 'fcYm.';           // fcYm.<id> = รอบที่ผู้ใช้ย้ายไปเอง
  function fcYmOverride(id) {
    try { return fcTrim(window.WTPOverride && WTPOverride.get(FC_YM_PREFIX + id)); } catch (_) { return ''; }
  }
  function fcRememberYm(id, ym) {
    try { WTPOverride.setRaw(FC_YM_PREFIX + id, ym || null); } catch (_) {}
  }
  // เลื่อนรอบเดือน (+1 / −1)
  function fcYmShift(ym, d) {
    const [y, m] = String(ym || '').split('-').map(Number);
    if (!y || !m) return '';
    const dt = new Date(Date.UTC(y, m - 1 + d, 1));
    return dt.getUTCFullYear() + '-' + String(dt.getUTCMonth() + 1).padStart(2, '0');
  }
  const FC_DEL_KEY = 'fcDeletedOf';
  function fcDeletedOfIds() {
    try { return String((window.WTPOverride && WTPOverride.get(FC_DEL_KEY)) || '').split('|').filter(Boolean); }
    catch (_) { return []; }
  }
  function fcRememberDeleted(ids) {
    try {
      const cur = fcDeletedOfIds();
      const next = cur.concat((ids || []).map(String)).filter(Boolean);
      WTPOverride.setRaw(FC_DEL_KEY, [...new Set(next)].slice(-500).join('|'));   // เก็บล่าสุด 500 ใบพอ
    } catch (_) {}
  }
  function fcParseDepSheet(aoa) {
    const out = [];
    if (!Array.isArray(aoa)) return out;
    for (let i = 0; i < aoa.length; i++) {
      const row = aoa[i] || [];
      const name = fcTrim(row[0]);
      if (/^department$/i.test(name)) break;            // ท้ายตาราง = ส่วน mapping แผนก
      const dept = fcTrim(row[1]).toUpperCase();
      if (!name || !dept || dept.length > 6) continue;
      out.push({ key: name, dept });                     // ชื่อผู้ถือบัตร → แผนก
      const plate = fcTrim(row[3]);
      if (plate) out.push({ key: plate, dept });         // ทะเบียนรถ → แผนก
    }
    return out;
  }
  /* อ่าน/เขียน override ของหน้านี้ผ่านตัวกลาง — WTPOverride.get แกะ JSON ทั้งก้อนทุกครั้งที่เรียก
   *   และ fcDeptOf ถูกเรียกทุกแถวทุกครั้งที่วาดหน้า → โหลดทั้งก้อนครั้งเดียวต่อ 5 วิ
   *   ค่าที่หน้านี้เพิ่งเขียนเองเก็บทับไว้ใน fcOvMine (อ่านได้ทันที ไม่ต้องรอ sync วนกลับมา) */
  let fcOvAll = null, fcOvAt = 0;
  const fcOvMine = {};
  function fcOvMap() {
    const now = Date.now();
    if (!fcOvAll || now - fcOvAt > 5000) {
      let all = null;
      try { all = (window.WTPOverride && WTPOverride._load) ? WTPOverride._load() : null; } catch (_) {}
      fcOvAll = all || {};
      fcOvAt = now;
    }
    return fcOvAll;
  }
  function fcOvGet(key) {
    if (Object.prototype.hasOwnProperty.call(fcOvMine, key)) return fcOvMine[key];
    return fcOvMap()[key];
  }
  function fcOvSet(key, value) {
    const v = value == null ? '' : String(value);
    fcOvMine[key] = v;
    try { WTPOverride.setRaw(key, v || null); return true; } catch (_) { return false; }
  }
  function fcSaveDeptMap(list) {
    let n = 0;
    (list || []).forEach(it => {
      const k = FC_DEPT_PREFIX + fcTxtNorm(it.key).slice(0, 40);
      if (!k || k === FC_DEPT_PREFIX) return;
      if (fcOvSet(k, it.dept)) n++;
    });
    return n;
  }
  function fcDeptLookup(txn) {
    if (!txn) return '';
    const tryKey = (v) => {
      const k = fcTxtNorm(v).slice(0, 40);
      return k ? fcTrim(fcOvGet(FC_DEPT_PREFIX + k)) : '';
    };
    return tryKey(txn.driverName) || tryKey(txn.plateNo) || '';
  }
  /* แผนกของรายการในบิล — 3 ชั้น:
   *   1. ผู้ใช้ระบุเองรายรายการ (fcDeptTxn.<id>) — บัตร VIP เป็นบัตรของฝ่ายการเงินที่แต่ละแผนกเบิกไปใช้
   *      ครั้งนี้แผนกหนึ่ง ครั้งหน้าอีกแผนก → ต้องถามเป็นรายรายการ ผูกกับบัตรไม่ได้
   *   2. ตารางผู้ถือบัตร (ชีต Dep. ที่ฝ่ายการเงินดูแลเอง) — ไฟล์งานเดิมก็ XLOOKUP จากชื่อผู้ถือบัตรอย่างเดียว
   *   3. ช่อง DEPARTMENT ของไฟล์ธนาคาร (มิ.ย. 2026 มี 2 คนที่ไม่ตรงกับตาราง Dep.: HRD / sod ↔ ESD / QCD)
   *   เก็บชั้น 1 ผ่าน override (ไม่ใช่คอลัมน์ในแถว) — นำเข้าไฟล์ธนาคารซ้ำแล้วไม่หาย เพราะ id ของรายการคงที่ */
  const FC_DEPT_TXN = 'fcDeptTxn.';
  const fcDeptManual = (t) => (t && t.id) ? fcTrim(fcOvGet(FC_DEPT_TXN + t.id)).toUpperCase() : '';
  const fcDeptAuto = (t) => fcDeptLookup(t) || fcTrim(t && t.deptCode);
  const fcDeptOf = (t) => fcDeptManual(t) || fcDeptAuto(t);
  // รหัสแผนกที่ระบบเคยเห็น (OF ใน Mango · บิล · ตาราง Dep. · ที่เคยระบุเอง) → ตัวเลือกในช่องระบุแผนก
  function fcDeptOptions(txns, mangos) {
    const name = {};
    const add = (code, nm) => {
      const c = fcTrim(code).toUpperCase();
      if (!c || c.length > 6) return;
      if (!name[c]) name[c] = fcTrim(nm);
    };
    (mangos || []).forEach(m => add(m.deptCode, m.deptName));
    (txns || []).forEach(t => add(fcDeptOf(t)));
    const all = Object.assign({}, fcOvMap(), fcOvMine);
    Object.keys(all).forEach(k => { if (k.indexOf(FC_DEPT_PREFIX) === 0 || k.indexOf(FC_DEPT_TXN) === 0) add(all[k]); });
    return Object.keys(name).sort().map(c => ({ code: c, name: name[c] }));
  }

  /* ── 3) เครื่องจับคู่ ธนาคาร ↔ Mango ────────────────────────────────
   *   ลำดับความเชื่อมั่น:
   *     1. ผู้ใช้จับคู่เอง/ยืนยันแล้ว (txn.mangoId — เก็บได้หลายใบคั่นด้วย ",")
   *     2. เลขใบกำกับตรงกัน (ถ้าไฟล์ Mango มี)
   *     3. ยอดตรง 1:1 + สัญญาณ (ปั๊ม/ร้านค้า · ทะเบียน · ชื่อผู้ถือบัตร · วันที่)
   *     4. ยอดตรงแบบ "รวมหลายบรรทัด OF = 1 รายการรูด" (N:1)
   *        — รูดครั้งเดียวได้ตั๋ว+ภาษีสนามบิน+ค่ากระเป๋า หรือเติมน้ำมันแล้วตั้งแยกใบ
   *     5. ยอดต่างเล็กน้อยแต่สัญญาณอื่นแน่น = "ตั้งไม่ครบ" (ค่าเงิน/ปัดเศษ)
   *   เทียบยอดทุกชั้นเผื่อ VAT 7% ด้วย (Mango ตั้งยอดก่อน VAT แต่บัตรรูดยอดรวม VAT) */
  const FC_MATCH_MIN = 55;          // คะแนนขั้นต่ำที่ยอมให้เป็นคู่อัตโนมัติ
  const FC_MATCH_SURE = 70;         // ต่ำกว่านี้ = จับได้แต่สัญญาณน้อย → ติดป้าย 'ตรวจซ้ำ'
  const FC_DATE_WIN = 7;            // วันที่ห่างกันได้ไม่เกิน (วัน) — ใช้เมื่อไฟล์มีวันที่
  const FC_COMBO_MAX = 4;           // รวมยอดได้สูงสุดกี่บรรทัด OF ต่อ 1 รายการรูด
  const FC_PARTIAL_PCT = 0.05;      // ยอดต่างได้ไม่เกิน 5% ถึงจะถือว่า "ตั้งไม่ครบ"
  const FC_PARTIAL_MAX = 3000;      // และต่างได้ไม่เกินกี่บาท
  const FC_VAT = 0.07;

  /* ── ยอดสองฝั่ง "ถือว่าตรงกัน" ไหม — ตรงเป๊ะ / ต่างกันเท่ากับ VAT 7% ──
   *   คืน { ok, kind: 'exact'|'vat'|'vat_rev', diff } */
  function fcAmtMatch(bankAmt, mangoAmt) {
    const b = fcNum(bankAmt), m = fcNum(mangoAmt);
    if (Math.abs(b - m) < 0.01) return { ok: true, kind: 'exact', diff: 0 };
    if (Math.abs(b - m * (1 + FC_VAT)) < 0.02) return { ok: true, kind: 'vat', diff: 0 };       // Mango ตั้งก่อน VAT
    if (Math.abs(b * (1 + FC_VAT) - m) < 0.02) return { ok: true, kind: 'vat_rev', diff: 0 };   // ตั้งรวม VAT แต่บัตรรูดก่อน VAT
    return { ok: false, kind: '', diff: b - m };
  }

  /* ── ตารางชื่อร้านบนบัตร (STM) ↔ ชื่อเจ้าหนี้ใน Mango ──
   *   บัตรเครดิตขึ้นชื่อ gateway/ภาษาอังกฤษ แต่ Mango ตั้งเป็นชื่อนิติบุคคลไทย
   *   เทียบตรงๆ ไม่มีทางตรง → ใช้ตารางนี้ + ตารางที่ "เรียนรู้" จากคู่ที่ผู้ใช้จับเอง */
  const FC_VENDOR_ALIAS = [
    { re: /2C2P\s*\*?\s*BAN|BANGKOK\s*AIRWAYS/i,      keys: ['การบินกรุงเทพ', 'BANGKOKAIRWAYS'] },
    { re: /2C2P\s*\*?\s*THA|THAI\s*AIRWAYS/i,          keys: ['การบินไทย', 'THAIAIRWAYS'] },
    { re: /THAILIONAIR|LION\s*AIR/i,                    keys: ['ไทยไลอ้อน', 'THAILION'] },
    { re: /AIR\s*ASIA|AIRASIA/i,                        keys: ['ไทยแอร์เอเชีย', 'แอร์เอเชีย', 'AIRASIA'] },
    { re: /VIETJET/i,                                    keys: ['เวียตเจ็ท', 'เวียดเจ็ท', 'VIETJET'] },
    // vat: true = ร้านนี้รูดบัตรรวม VAT 7% เสมอ แต่ใบ OF ตั้งยอดก่อน VAT → ยอดต่างกันเท่า VAT คือ "ปกติ" ไม่ต้องให้ตรวจ
    { re: /LINE\s*OFFICIAL|LP_LINE|LINEOFFICIALACCOU/i, keys: ['LINE'], vat: true },
    { re: /GOOGLE/i,                                     keys: ['GOOGLE'] },
    { re: /NOK\s*AIR|2C2P\s*\*?\s*NOK/i,                keys: ['สายการบินนกแอร์', 'NOKAIR'] },
    { re: /AGODA|BOOKING|TRAVELOKA|TRIP\.COM/i,          keys: ['AGODA', 'BOOKING', 'TRAVELOKA', 'TRIP'] },
    { re: /MICROSOFT|ADOBE|CANVA|ZOOM|META\s*PLATFORM|FACEBOOK/i, keys: ['MICROSOFT', 'ADOBE', 'CANVA', 'ZOOM', 'META', 'FACEBOOK'] },
  ];
  // คู่ชื่อที่ผู้ใช้จับเองแล้วระบบจำไว้ (sync ผ่าน manualOverrides — ทีมเห็นเหมือนกัน)
  const FC_ALIAS_PREFIX = 'fcAlias.';
  const fcAliasKey = (stmName) => FC_ALIAS_PREFIX + fcTxtNorm(stmName).slice(0, 40);
  function fcLearnedAlias(stmName) {
    try {
      const v = window.WTPOverride && WTPOverride.get(fcAliasKey(stmName));
      return v ? String(v).split('|').filter(Boolean) : [];
    } catch (_) { return []; }
  }
  function fcRememberAlias(stmName, mangoVendor) {
    const k = fcAliasKey(stmName), v = fcTxtNorm(mangoVendor).slice(0, 40);
    if (!v || fcTxtNorm(stmName).length < 4) return;
    try {
      const cur = fcLearnedAlias(stmName);
      if (cur.indexOf(v) >= 0) return;
      WTPOverride.setRaw(k, cur.concat([v]).slice(-5).join('|'));
    } catch (_) {}
  }
  /* ชื่อร้านบนบัตร/ปั๊ม ↔ ชื่อเจ้าหนี้ใน Mango น่าจะที่เดียวกันไหม
   *   คืน { ok, via: 'name'|'alias'|'learned' } */
  function fcVendorMatch(bankName, bankName2, mangoVendor) {
    if (!mangoVendor) return { ok: false, via: '' };
    if (fcNameLike(mangoVendor, bankName) || fcNameLike(mangoVendor, bankName2)) return { ok: true, via: 'name' };
    const mv = fcTxtNorm(mangoVendor);
    const stm = String(bankName || '') + ' ' + String(bankName2 || '');
    for (const a of FC_VENDOR_ALIAS) {
      if (!a.re.test(stm)) continue;
      if (a.keys.some(k => mv.indexOf(fcTxtNorm(k)) >= 0)) return { ok: true, via: 'alias' };
    }
    const learned = fcLearnedAlias(bankName).concat(fcLearnedAlias(bankName2));
    if (learned.some(v => v && (mv.indexOf(v) >= 0 || v.indexOf(mv) >= 0))) return { ok: true, via: 'learned' };
    return { ok: false, via: '' };
  }

  /* ── ให้คะแนนคู่ (ยอดผ่านเกณฑ์มาแล้ว) ── */
  function fcScorePair(t, m, ctx) {
    const c = ctx || {};
    let score = 40;
    const why = [c.amtKind === 'exact' ? 'ยอดตรง' : 'ยอดตรงเมื่อคิด VAT 7%'];
    if (c.amtKind !== 'exact') score += 4;   // ตรงพอดีเมื่อคูณ 1.07 = บังเอิญยาก
    // ร้านที่รู้อยู่แล้วว่ารูดรวม VAT (LINE) — ยอดบัตร = OF × 1.07 คือสิ่งที่ควรเป็น → นับเป็นคู่ที่มั่นใจ
    if (c.amtKind === 'vat') {
      const stm = String(t.pp20Name || '') + ' ' + String(t.merchantName || '');
      if (FC_VENDOR_ALIAS.some(a => a.vat && a.re.test(stm))) { score += 20; why.push('ร้านนี้รูดรวม VAT เป็นปกติ'); }
    }
    const dd = (t.trnDate && m.eventDate) ? fcDateDiff(t.trnDate, m.eventDate) : 999;
    if (dd === 0) { score += 25; why.push('วันเดียวกัน'); }
    else if (dd <= 1) { score += 18; why.push('วันต่างกัน 1 วัน'); }
    else if (dd <= 3) { score += 10; why.push('วันต่างกัน ' + dd + ' วัน'); }
    else if (dd <= FC_DATE_WIN) { score += 4; why.push('วันต่างกัน ' + dd + ' วัน'); }
    else if (dd !== 999) { score -= 25; why.push('วันห่าง ' + dd + ' วัน'); }
    else {
      // OF 9.3 ไม่มีวันที่เติม — ใช้วันที่เอกสารเป็นตัวช่วย (ไม่ใช่วันรูดจริง จึงให้แต้มอย่างเดียว ไม่หัก)
      const soft = (t.trnDate && (m.refDate || m.vchDate)) ? fcDateDiff(t.trnDate, m.refDate || m.vchDate) : 999;
      if (soft === 0) { score += 12; why.push('วันที่เอกสารตรงวันรูด'); }
      else if (soft <= 3) { score += 6; why.push('วันที่เอกสารห่าง ' + soft + ' วัน'); }
      else if (soft <= 7) { score += 3; }
    }
    // ชื่อปั๊ม/ร้านค้า (รองรับชื่อย่อ-เต็ม และตารางชื่อ gateway ↔ เจ้าหนี้)
    const vm = fcVendorMatch(t.pp20Name, t.merchantName, m.vendor);
    if (vm.ok) {
      score += vm.via === 'name' ? 20 : 16;
      why.push(vm.via === 'name' ? 'ชื่อปั๊มตรง' : (vm.via === 'alias' ? 'ชื่อร้านตรงตามตารางชื่อ' : 'ชื่อร้านตรง (ระบบเคยเรียนรู้)'));
    }
    // ทะเบียนรถ — itemname · หมายเหตุหัวใบ · pre_event2 (เลขท้ายทะเบียน)
    const mp = fcPlateNorm(m.plateNo), tp = fcPlateNorm(t.plateNo);
    const bankVip = /^VIP/i.test(fcTrim(t.plateNo));
    const mTail = fcDigits(m.plateTail || m.plateNo).slice(-4);
    const tDigits = fcDigits(t.plateNo);
    const plateSame = mp && tp && (mp === tp || (mp.length >= 4 && tp.indexOf(mp) >= 0) || (tp.length >= 4 && mp.indexOf(tp) >= 0));
    const tailSame = mTail.length >= 3 && tDigits.indexOf(mTail) >= 0;
    if (plateSame) { score += 20; why.push('ทะเบียนตรง'); }
    else if (tailSame) { score += 14; why.push('เลขทะเบียนตรง'); }
    else if (mp && tp && !bankVip) { score -= 12; why.push('ทะเบียนไม่ตรง (' + m.plateNo + ')'); }
    // ชื่อผู้ถือบัตรใน itemname / หมายเหตุหัวใบ
    const drv = fcTxtNorm(t.driverName);
    if (drv.length >= 6 && (fcTxtNorm(m.itemName).indexOf(drv) >= 0 || fcTxtNorm(m.docRemark).indexOf(drv) >= 0)) {
      score += 15; why.push('ชื่อผู้ถือบัตรตรง');
    }
    if (t.deptCode && m.deptCode && t.deptCode.toUpperCase() === m.deptCode.toUpperCase()) { score += 6; why.push('แผนกตรง'); }
    if (c.uniqueAmount) { score += 18; why.push('ยอดนี้มีรายการเดียว'); }
    if (c.comboSize > 1) { score += 8; why.push('รวม ' + c.comboSize + ' บรรทัดใน OF เดียวกัน'); }
    if (c.partial) { score -= 8; why.push('ตั้งไม่ครบ ขาด ' + fmtNum(Math.abs(c.diff), 2)); }
    if (c.crossYm) { score -= 10; why.push('OF รอบ ' + fcFmtMonth(m.ym)); }
    return { score, why: why.join(' · '), days: dd };
  }
  // คะแนนของคู่แบบรวมหลายบรรทัด — ใช้บรรทัดที่ได้คะแนนดีสุดเป็นตัวแทน
  function fcScoreCombo(t, rows, ctx) {
    let best = null;
    rows.forEach(m => {
      const s = fcScorePair(t, m, ctx);
      if (!best || s.score > best.score) best = s;
    });
    return best || { score: 0, why: '', days: 999 };
  }

  /* ── หา "ชุด" ของบรรทัด OF ที่รวมกันแล้วเท่ายอดรูด (N:1) ──
   *   จำกัดให้ปลอดภัย: อยู่ในใบ OF เดียวกัน · ไม่เกิน FC_COMBO_MAX บรรทัด
   *   (ใบเดียวกัน = ตั้งคราวเดียวกัน ซึ่งตรงกับพฤติกรรมจริง เช่น ตั๋ว+ภาษีสนามบิน) */
  function fcFindCombo(target, pool) {
    const byDoc = {};
    pool.forEach(m => { const d = m.docNo || '—'; (byDoc[d] = byDoc[d] || []).push(m); });
    let best = null;
    Object.keys(byDoc).forEach(doc => {
      const rows = byDoc[doc].slice().sort((a, b) => fcNum(b.amount) - fcNum(a.amount));
      if (rows.length < 2) return;
      const n = Math.min(rows.length, 12);          // กันระเบิด combination ในใบที่มีหลายสิบบรรทัด
      const pick = [];
      const walk = (start, sum, depth) => {
        if (best) return;
        if (depth >= 2) {
          const am = fcAmtMatch(target, sum);
          if (am.ok) { best = { rows: pick.slice(), amtKind: am.kind }; return; }
        }
        if (depth >= FC_COMBO_MAX) return;
        for (let i = start; i < n; i++) {
          const v = fcNum(rows[i].amount);
          if (sum + v > fcNum(target) * (1 + FC_VAT) + 0.02) continue;   // ตัดกิ่งที่เกินยอดแน่ๆ
          pick.push(rows[i]);
          walk(i + 1, sum + v, depth + 1);
          pick.pop();
          if (best) return;
        }
      };
      walk(0, 0, 0);
    });
    return best;
  }

  function fcReconcile(txns, mangos, opts) {
    const ym = (opts && opts.ym) || '';
    const mById = {};
    mangos.forEach(m => { mById[m.id] = m; });
    const usedM = {};
    const pairs = [];
    const rest = [];
    const mk = (txn, rows, how, score, why, extra) => Object.assign({
      txn, mangos: rows, mango: rows[0], how, score, why,
      diff: fcNum(txn.amount) - rows.reduce((s, r) => s + fcNum(r.amount), 0),
    }, extra || {});

    // 1) จับคู่ที่ผู้ใช้ยืนยัน/ทำเองไว้แล้ว — ชนะทุกกรณี (mangoId เก็บได้หลายใบ คั่นด้วย ",")
    txns.forEach(t => {
      const ids = String(t.mangoId || '').split(',').map(s => s.trim()).filter(Boolean);
      const rows = ids.map(id => mById[id]).filter(m => m && !usedM[m.id]);
      if (rows.length && rows.length === ids.length) {
        rows.forEach(m => { usedM[m.id] = true; });
        pairs.push(mk(t, rows, 'manual', 100, 'จับคู่เอง/ยืนยันแล้ว'));
      } else rest.push(t);
    });

    // 2) เลขใบกำกับตรงกัน
    const byInv = {};
    mangos.forEach(m => {
      const k = fcDigits(m.invoiceNo);
      if (k.length >= 8 && !usedM[m.id]) (byInv[k] = byInv[k] || []).push(m);
    });
    const rest2 = [];
    rest.forEach(t => {
      const k = fcDigits(t.invoiceNo);
      const cand = (k.length >= 8 ? (byInv[k] || []) : []).filter(m => !usedM[m.id]);
      if (cand.length) {
        usedM[cand[0].id] = true;
        pairs.push(mk(t, [cand[0]], 'invoice', 100, 'เลขใบกำกับตรงกัน'));
      } else rest2.push(t);
    });

    // 3) ยอดตรง 1:1 (รวมเคส VAT) — ทำ candidate ทั้งหมดแล้วเลือกคะแนนสูงก่อน (greedy)
    const cands = [];
    const avail = () => mangos.filter(m => !usedM[m.id]);
    const pool1 = avail();
    rest2.forEach(t => {
      const hits = pool1.filter(m => fcAmtMatch(t.amount, m.amount).ok);
      const sameAmtTxn = rest2.filter(x => fcAmtMatch(x.amount, t.amount).ok).length;
      hits.forEach(m => {
        const am = fcAmtMatch(t.amount, m.amount);
        const s = fcScorePair(t, m, {
          amtKind: am.kind, uniqueAmount: hits.length === 1 && sameAmtTxn === 1,
          crossYm: !!(ym && m.ym && m.ym !== ym),
        });
        if (s.score >= FC_MATCH_MIN) cands.push({ txn: t, rows: [m], score: s.score, why: s.why });
      });
    });
    cands.sort((a, b) => b.score - a.score);
    const usedT = {};
    cands.forEach(c => {
      if (usedT[c.txn.id] || c.rows.some(m => usedM[m.id])) return;
      usedT[c.txn.id] = true; c.rows.forEach(m => { usedM[m.id] = true; });
      pairs.push(mk(c.txn, c.rows, 'auto', c.score, c.why, { weak: c.score < FC_MATCH_SURE }));
    });

    // 4) รวมหลายบรรทัดใน OF ใบเดียว = 1 รายการรูด (N:1)
    rest2.filter(t => !usedT[t.id]).forEach(t => {
      const combo = fcFindCombo(t.amount, avail());
      if (!combo) return;
      const s = fcScoreCombo(t, combo.rows, {
        amtKind: combo.amtKind, comboSize: combo.rows.length,
        crossYm: !!(ym && combo.rows[0].ym && combo.rows[0].ym !== ym),
      });
      if (s.score < FC_MATCH_MIN) return;
      usedT[t.id] = true; combo.rows.forEach(m => { usedM[m.id] = true; });
      pairs.push(mk(t, combo.rows, 'combo', s.score, s.why, { weak: s.score < FC_MATCH_SURE }));
    });

    // 5) ยอดต่างเล็กน้อย แต่สัญญาณอื่นแน่น = ตั้งไม่ครบ (ค่าเงิน/ปัดเศษ/ตั้งขาด)
    const partials = [];
    rest2.filter(t => !usedT[t.id]).forEach(t => {
      const near = avail().filter(m => {
        const ad = Math.abs(fcNum(t.amount) - fcNum(m.amount));
        return ad >= 0.01 && ad <= FC_PARTIAL_MAX && ad <= fcNum(t.amount) * FC_PARTIAL_PCT;
      });
      // ในบรรดาใบที่ยอดใกล้กัน มีกี่ใบที่ชื่อร้านตรงด้วย — ถ้าตรงใบเดียวก็ไม่กำกวมแล้ว
      const vendorHits = near.filter(m => fcVendorMatch(t.pp20Name, t.merchantName, m.vendor).ok).length;
      near.forEach(m => {
        const diff = fcNum(t.amount) - fcNum(m.amount);
        const vOk = fcVendorMatch(t.pp20Name, t.merchantName, m.vendor).ok;
        const s = fcScorePair(t, m, { amtKind: 'exact', partial: true, diff,
          uniqueAmount: near.length === 1 || (vOk && vendorHits === 1),
          crossYm: !!(ym && m.ym && m.ym !== ym) });
        if (s.score >= FC_MATCH_MIN) partials.push({ txn: t, rows: [m], score: s.score, why: s.why, diff });
      });
    });
    partials.sort((a, b) => b.score - a.score);
    partials.forEach(c => {
      if (usedT[c.txn.id] || usedM[c.rows[0].id]) return;
      usedT[c.txn.id] = true; usedM[c.rows[0].id] = true;
      pairs.push(mk(c.txn, c.rows, 'partial', c.score, c.why, { weak: true, shortfall: c.diff }));
    });

    const txnOnly = rest2.filter(t => !usedT[t.id]);
    // "ตั้งเกิน" นับเฉพาะ OF ของรอบที่เลือก — รอบข้างเคียงที่ดึงมาช่วยจับคู่ไม่ใช่ของรอบนี้
    const mangoOnly = mangos.filter(m => !usedM[m.id] && (!ym || !m.ym || m.ym === ym));
    return { pairs, txnOnly, mangoOnly };
  }

  // OF ใบนี้ถูกดึงไปตัดจ่ายแล้วหรือยัง (มีเลข PV = ตัดแล้ว)
  const fcIsPaid = (m) => !!(m && (fcTrim(m.pvNo) || /^y/i.test(fcTrim(m.payStatus))));
  function fcPayState(m) {
    if (!m || !fcIsPaid(m)) return 'none';
    const amt = fcNum(m.docAmount), paid = fcNum(m.docPaidAmount);
    if (!amt) return 'full';                       // ไม่ได้นำเข้ารายงาน OF ยอดรวม → รู้แค่ว่ามี PV
    if (Math.abs(paid - amt) < 0.01) return 'full';
    if (paid > 0.009 && paid < amt) return 'partial';   // จ่ายบางส่วน — ที่เหลือรอตัดรอบถัดไป
    return paid > 0 ? 'full' : 'none';
  }

  /* ── 4) รวมสถิติของเดือน ─────────────────────────────────────────── */
  function fcSummarize(txns, mangos, recon) {
    const sum = (arr, f) => arr.reduce((s, r) => s + fcNum(f(r)), 0);
    const pending = recon.txnOnly.filter(t => t.status !== 'ignored');
    const ignored = recon.txnOnly.filter(t => t.status === 'ignored');
    const paidPairs = recon.pairs.filter(p => fcIsPaid(p.mango));
    const unpaidPairs = recon.pairs.filter(p => !fcIsPaid(p.mango));
    const hasPayInfo = mangos.some(m => fcTrim(m.pvNo) || fcTrim(m.payStatus));
    return {
      hasPayInfo,
      paidAmt: sum(paidPairs, p => p.txn.amount), paidCount: paidPairs.length,
      unpaidAmt: sum(unpaidPairs, p => p.txn.amount), unpaidCount: unpaidPairs.length,
      bankAmt: sum(txns, r => r.amount),
      bankLitre: sum(txns, r => r.qtyLitre),
      bankCount: txns.length,
      mangoAmt: sum(mangos, r => r.amount),
      mangoCount: mangos.length,
      matchedAmt: sum(recon.pairs, p => p.txn.amount),
      matchedCount: recon.pairs.length,
      shortfallAmt: sum(recon.pairs.filter(p => p.shortfall), p => Math.abs(p.shortfall)),
      shortfallCount: recon.pairs.filter(p => p.shortfall).length,
      weakCount: recon.pairs.filter(p => p.weak).length,
      pendingAmt: sum(pending, r => r.amount),
      pendingCount: pending.length,
      ignoredCount: ignored.length,
      overAmt: sum(recon.mangoOnly, r => r.amount),
      overCount: recon.mangoOnly.length,
    };
  }
  // จัดกลุ่มสรุป (รายบัตร / รายแผนก / ชนิดน้ำมัน / ปั๊ม)
  function fcGroup(rows, keyFn, labelFn) {
    const map = {};
    rows.forEach(r => {
      const k = keyFn(r) || '—';
      if (!map[k]) map[k] = { key: k, label: labelFn ? labelFn(r) : k, count: 0, amount: 0, litre: 0 };
      map[k].count += 1;
      map[k].amount += fcNum(r.amount);
      map[k].litre += fcNum(r.qtyLitre);
    });
    return Object.values(map).sort((a, b) => b.amount - a.amount);
  }

  /* ── UI: ป้ายสถานะ ───────────────────────────────────────────────── */
  function FcTag({ kind, children, title }) {
    const S = {
      ok: { bg: 'var(--good-bg)', fg: 'var(--good)' },
      warn: { bg: 'var(--warn-bg)', fg: 'var(--warn)' },
      bad: { bg: 'var(--bad-bg)', fg: 'var(--bad)' },
      mute: { bg: 'var(--ink-100)', fg: 'var(--ink-600)' },
    }[kind] || { bg: 'var(--ink-100)', fg: 'var(--ink-600)' };
    return (
      <span title={title} style={{ background: S.bg, color: S.fg, fontSize: 11, fontWeight: 700,
        borderRadius: 999, padding: '2px 9px', whiteSpace: 'nowrap', display: 'inline-block' }}>{children}</span>
    );
  }

  /* ── UI: KPI ─────────────────────────────────────────────────────── */
  function FcKpi({ label, value, sub, color, icon }) {
    return (
      <div className="kpi">
        <div className="kpi-accent" style={{ background: color }} />
        <div className="kpi-label">{icon ? <Icon name={icon} size={13} /> : null}{label}</div>
        <div className="kpi-value" style={{ fontSize: 22, color }}>{value}</div>
        {sub ? <div style={{ fontSize: 11.5, color: 'var(--ink-500)', marginTop: 2 }}>{sub}</div> : null}
      </div>
    );
  }

  /* ── UI: modal นำเข้าไฟล์ (ใช้ทั้งฝั่งธนาคารและฝั่ง Mango) ─────────── */
  function FcImportModal({ kind, defaultYm, program, otherCustomerIds, onClose, onCommit }) {
    const prog = fcProgInfo(program);
    const isBank = kind === 'bank';
    const isExec = program === 'exec';
    const [items, setItems] = useState([]);      // { key, name, status, rows, ym, error, sheetNames, sheets, sheet }
    const [drag, setDrag] = useState(false);
    const [ym, setYm] = useState(defaultYm || '');
    const inputRef = useRef(null);

    const addFiles = (fileList) => {
      const files = Array.from(fileList || []);
      files.forEach(file => {
        const key = file.name + '|' + file.size + '|' + Math.random().toString(36).slice(2, 7);
        setItems(prev => prev.concat([{ key, name: file.name, status: 'reading', rows: [], ym: '', error: '' }]));
        if (isBank && isExec) {
          // บัตรผู้บริหาร — statement บัตรเครดิต (.xlsx จากธนาคาร)
          fcReadWorkbook(file).then(wb => {
            const sheet = wb.sheetNames[0];
            const parsed = fcParseExecStatement(wb.sheets[sheet] || [], file.name);
            const expense = parsed.rows.filter(r => r.txnType === 'expense');
            setItems(prev => prev.map(it => it.key === key ? {
              ...it, status: parsed.error ? 'error' : 'ready',
              rows: parsed.rows, ym: parsed.ym, error: parsed.error,
              execMeta: { cardNo: parsed.cardNo, cardHolder: parsed.cardHolder,
                statementDate: parsed.statementDate, dueDate: parsed.dueDate,
                expenseCount: expense.length, otherCount: parsed.rows.length - expense.length },
            } : it));
            if (parsed.ym) setYm(cur => cur || parsed.ym);
          }).catch(err => {
            const msg = String((err && err.message) || err).toLowerCase();
            setItems(prev => prev.map(it => it.key === key ? {
              ...it, status: 'error',
              error: /password|encrypt/.test(msg)
                ? 'ไฟล์มีรหัส — เปิดใน Excel แล้ว Save As ใหม่ (ไม่ใส่รหัส) ก่อน'
                : 'อ่านไฟล์ไม่ได้ — รองรับ .xlsx / .xls / .csv',
            } : it));
          });
        } else if (isBank) {
          file.arrayBuffer().then(buf => {
            const parsed = fcParseBankText(fcDecodeText(buf), file.name);
            setItems(prev => prev.map(it => it.key === key ? {
              ...it, status: parsed.error ? 'error' : 'ready',
              rows: parsed.rows, ym: parsed.ym, error: parsed.error,
            } : it));
            if (parsed.ym) setYm(cur => cur || parsed.ym);
          }).catch(err => setItems(prev => prev.map(it => it.key === key
            ? { ...it, status: 'error', error: 'อ่านไฟล์ไม่ได้: ' + ((err && err.message) || err) } : it)));
        } else {
          fcReadWorkbook(file).then(wb => {
            // ไฟล์ DATA ของฝ่ายการเงินมีชีต 'Dep.' (ผู้ถือบัตร → แผนก) มาด้วย — เก็บไว้ใช้เติมแผนก
            let deptSaved = 0;
            const depName = wb.sheetNames.find(n => /^dep.?$/i.test(fcTrim(n)));
            if (depName) { try { deptSaved = fcSaveDeptMap(fcParseDepSheet(wb.sheets[depName] || [])); } catch (_) {} }
            const sheet = fcPickMangoSheet(wb.sheetNames, wb.sheets);
            const parsed = fcParseMangoAoa(wb.sheets[sheet] || []);
            setItems(prev => prev.map(it => it.key === key ? {
              ...it, status: parsed.error ? 'error' : 'ready', rows: parsed.rows, error: parsed.error,
              mangoKind: parsed.kind, deptSaved, sheetNames: wb.sheetNames, sheets: wb.sheets, sheet,
            } : it));
          }).catch(err => {
            const msg = String((err && err.message) || err).toLowerCase();
            setItems(prev => prev.map(it => it.key === key ? {
              ...it, status: 'error',
              error: /password|encrypt/.test(msg)
                ? 'ไฟล์มีรหัส — เปิดใน Excel แล้ว Save As ใหม่ (ไม่ใส่รหัส) ก่อน'
                : 'อ่านไฟล์ไม่ได้ — รองรับ .xlsx / .xls / .csv',
            } : it));
          });
        }
      });
    };
    const changeSheet = (key, sheet) => setItems(prev => prev.map(it => {
      if (it.key !== key) return it;
      const parsed = fcParseMangoAoa((it.sheets || {})[sheet] || []);
      return { ...it, sheet, rows: parsed.rows, error: parsed.error, mangoKind: parsed.kind,
        status: parsed.error ? 'error' : 'ready' };
    }));
    const removeItem = (key) => setItems(prev => prev.filter(it => it.key !== key));
    const onDrop = (e) => { e.preventDefault(); setDrag(false); if (e.dataTransfer) addFiles(e.dataTransfer.files); };

    const ready = items.filter(it => it.status === 'ready');
    // ฝั่ง Mango: รวมไฟล์รายละเอียด (OF 9.3) กับไฟล์หัวใบ (OF ยอดรวม) เข้าด้วยกันด้วย docno
    //   หลายไฟล์ชนิดเดียวกัน (ลากไฟล์เดิมซ้ำ / ช่วงวันที่คาบเกี่ยว) → รวมแบบไม่นับแถวเดียวกันซ้ำ
    const details = fcUnionRows(ready.filter(it => it.mangoKind === 'detail').map(it => it.rows), fcMangoRowKey);
    const headers = fcUnionRows(ready.filter(it => it.mangoKind === 'header').map(it => it.rows), r => r.docNo);
    const mangoRows = isBank ? [] : fcMergeMango(details, headers);
    const outRows = isBank ? ready.reduce((acc, it) => acc.concat(it.rows), []) : mangoRows;
    // นับ/รวมยอดเฉพาะ "ค่าใช้จ่าย" — statement บัตรเครดิตมียอดยกมา/ตัดชำระ/เงินคืนปนมาด้วย
    const expRows = outRows.filter(r => !r.txnType || r.txnType === 'expense');
    const totalRows = expRows.length;
    const totalAmt = expRows.reduce((a, r) => a + fcNum(r.amount), 0);
    const skipRows = outRows.length - expRows.length;
    // รอบเดือนที่พบในไฟล์ Mango (มาจากเลข OF) — ไฟล์เดียวมีได้หลายรอบ
    const mangoYms = isBank ? [] : Object.keys(mangoRows.reduce((a, r) => { if (r.ym) a[r.ym] = 1; return a; }, {})).sort();

    // ไฟล์ธนาคารที่ลากเข้ามา เป็นของชุดบัตรอีกฝั่งหรือเปล่า (ดูจากเลขลูกค้าในไฟล์)
    const fileCustomers = isBank
      ? Object.keys(ready.reduce((a, it) => { it.rows.forEach(r => { if (r.customerId) a[r.customerId] = 1; }); return a; }, {}))
      : [];
    const wrongSet = fileCustomers.filter(c => (otherCustomerIds || []).indexOf(c) >= 0);
    // ไฟล์ OF บอกชุดบัตรมาในตัว (ชื่อบัญชีเจ้าหนี้บัตร) — ไม่ตรงกับแท็บที่เปิด = กำลังจะวางผิดที่
    const fileProgram = isBank ? '' : fcGuessProgram(mangoRows);
    const wrongProgram = !!fileProgram && fileProgram !== program;

    const canCommit = isBank
      ? (ready.length > 0 && /^\d{4}-\d{2}$/.test(ym))
      : (mangoRows.length > 0 && mangoYms.length > 0 && !wrongProgram);   // ไฟล์ผิดชุดบัตร = กดนำเข้าไม่ได้

    return (
      <Modal open maxWidth={880} onClose={onClose}
        title={(isBank ? '📥 นำเข้าไฟล์เรียกเก็บจากธนาคาร' : '📒 นำเข้ารายงาน OF จาก Mango') + ' — ' + prog.label}
        footer={<>
          <span style={{ marginRight: 'auto', fontSize: 12, color: totalRows ? 'var(--good)' : 'var(--ink-500)' }}>
            {totalRows ? (totalRows + ' รายการ · ' + fmtMoney(totalAmt) + ' บาท'
              + (skipRows ? ' (+' + skipRows + ' แถวยอดยกมา/ตัดชำระ)' : '')
              + (!isBank && mangoYms.length ? ' · รอบ ' + mangoYms.map(fcFmtMonth).join(', ') : ''))
              : 'ยังไม่ได้เลือกไฟล์'}
          </span>
          <button className="btn btn-ghost" onClick={onClose}>ยกเลิก</button>
          <button className="btn btn-primary" disabled={!canCommit}
            onClick={() => onCommit(outRows, isBank ? ym : mangoYms)}>
            {isBank ? ('นำเข้ารอบ ' + fcFmtMonth(ym)) : ('นำเข้า ' + mangoYms.length + ' รอบ')}
          </button>
        </>}>

        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, padding: '8px 12px', borderRadius: 8,
          background: 'var(--ink-50)', fontSize: 12, color: 'var(--ink-700)' }}>
          <span style={{ fontSize: 16 }}>{prog.icon}</span>
          <span>ข้อมูลจะเข้า <b>{prog.label}</b> เท่านั้น — {prog.sub}</span>
        </div>

        {wrongProgram && (
          <div style={{ marginBottom: 12, padding: '12px 14px', borderRadius: 10, background: 'var(--bad-bg)',
            border: '1px solid var(--bad)', color: 'var(--ink-800)', fontSize: 12.5, lineHeight: 1.7 }}>
            <div style={{ fontWeight: 800, fontSize: 13.5, color: 'var(--bad)' }}>
              🚫 ไฟล์นี้เป็นของ {fcProgInfo(fileProgram).label} — แต่กำลังจะนำเข้าที่ {prog.label}
            </div>
            ดูจากชื่อบัญชีเจ้าหนี้ในไฟล์ ({fcTrim((mangoRows.find(r => fcTrim(r.acctName)) || {}).acctName) || '—'})
            <div style={{ marginTop: 6 }}>
              ปิดหน้าต่างนี้ → สลับไปแท็บ <b>{fcProgInfo(fileProgram).label}</b> แล้วค่อยนำเข้าใหม่
            </div>
          </div>
        )}
        {wrongSet.length > 0 && (
          <div style={{ marginBottom: 10, padding: '8px 12px', borderRadius: 8, background: 'var(--warn-bg)',
            color: 'var(--ink-800)', fontSize: 12 }}>
            ⚠ เลขลูกค้าในไฟล์ ({wrongSet.join(', ')}) เคยนำเข้าที่ชุดบัตรอีกฝั่งแล้ว — ตรวจว่าเปิดแท็บถูกชุดก่อนกดนำเข้า
          </div>
        )}

        <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', marginBottom: 14, transition: 'all .15s',
            background: drag ? 'color-mix(in oklch, var(--brand-500) 9%, #fff)' : 'var(--ink-50)' }}>
          <div style={{ fontSize: 28, marginBottom: 2 }}>{isBank ? (isExec ? '💳' : '⛽') : '📒'}</div>
          <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--ink-700)' }}>ลากไฟล์มาวางที่นี่ หรือกดเพื่อเลือก</div>
          <div style={{ fontSize: 11.5, color: 'var(--ink-500)', marginTop: 3 }}>
            {isBank
              ? (isExec
                ? 'ไฟล์ statement บัตรเครดิตจากธนาคาร (.xlsx ดิบๆ ไม่ต้องแก้) — ระบบแยกยอดยกมา / ตัดชำระ / เงินคืน ออกให้เอง'
                : 'ไฟล์ REPORT001 ของธนาคาร (.csv คั่นด้วย | · ภาษาไทย TIS-620 อ่านได้เลย ไม่ต้องแปลง)')
              : 'ลากมาพร้อมกันได้ทั้ง 2 ไฟล์ — รายงาน OF 9.3 (รายรายการ) + รายงาน OF ยอดรวม (เลข PV / สถานะจ่าย)'}
          </div>
          <input ref={inputRef} type="file" multiple style={{ display: 'none' }}
            accept={isBank && !isExec ? '.csv,.txt' : '.xlsx,.xls,.csv,.txt'}
            onChange={e => { addFiles(e.target.files); e.target.value = ''; }} />
        </div>

        {isBank ? (
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12, flexWrap: 'wrap' }}>
            <span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--ink-700)' }}>รอบเดือน</span>
            <input className="input" type="month" value={ym} onChange={e => setYm(e.target.value)}
              style={{ width: 160, fontSize: 12.5 }} />
            <span style={{ fontSize: 11.5, color: 'var(--ink-500)' }}>{isExec ? 'ดึงจากวันสรุปยอดในไฟล์ให้อัตโนมัติ — แก้ได้' : 'ดึงจาก POST_DATE ในไฟล์ให้อัตโนมัติ — แก้ได้'}</span>
          </div>
        ) : (
          <div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', fontSize: 11.5 }}>
            <FcTag kind={details.length ? 'ok' : 'mute'}>
              {details.length ? '✓ รายงาน OF 9.3 — ' + details.length + ' รายการ' : 'ยังไม่มีรายงาน OF 9.3 (รายรายการ)'}
            </FcTag>
            <FcTag kind={headers.length ? 'ok' : 'warn'}>
              {headers.length ? '✓ รายงาน OF ยอดรวม — ' + headers.length + ' ใบ' : 'ยังไม่มีรายงาน OF ยอดรวม (จะไม่รู้ว่าตัดจ่ายแล้วหรือยัง)'}
            </FcTag>
            {mangoYms.length > 0 && <FcTag kind="mute">รอบที่พบ: {mangoYms.map(fcFmtMonth).join(' · ')}</FcTag>}
          </div>
        )}

        {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: '34%' }}>ไฟล์</th>
                {!isBank && <th style={{ textAlign: 'left', width: '24%' }}>ชีต</th>}
                <th style={{ textAlign: 'right', width: '12%' }}>รายการ</th>
                <th style={{ textAlign: 'right', width: '15%' }}>ยอดรวม</th>
                <th style={{ textAlign: 'left', width: isBank ? '29%' : '10%' }}>สถานะ</th>
                <th style={{ width: '5%' }}></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>
                    {!isBank && (
                      <td>
                        {it.sheetNames && it.sheetNames.length > 1 ? (
                          <select className="select input" value={it.sheet} onChange={e => changeSheet(it.key, e.target.value)}
                            style={{ fontSize: 11.5, padding: '3px 6px', width: '100%' }}>
                            {it.sheetNames.map(n => <option key={n} value={n}>{n}</option>)}
                          </select>
                        ) : <span style={{ color: 'var(--ink-400)' }}>{it.sheet || '—'}</span>}
                      </td>
                    )}
                    <td className="num">{it.status === 'reading' ? '…' : it.rows.filter(r => !r.txnType || r.txnType === 'expense').length}</td>
                    <td className="num">
                      {it.rows.length
                        ? fmtMoney(it.rows.filter(r => !r.txnType || r.txnType === 'expense')
                            .reduce((s, r) => s + fcNum(it.mangoKind === 'header' ? r.docAmount : r.amount), 0))
                        : '—'}
                    </td>
                    <td style={{ fontSize: 11.5 }}>
                      {it.status === 'reading' && <span style={{ color: 'var(--ink-400)' }}>⏳ กำลังอ่าน…</span>}
                      {it.status === 'ready' && it.execMeta && (
                        <span style={{ color: 'var(--good)' }} title={it.execMeta.cardHolder}>
                          ✓ บัตร {it.execMeta.cardNo || '—'} · สรุปยอด {it.execMeta.statementDate || '—'}
                          {it.execMeta.otherCount ? ' · ข้ามยอดยกมา/ตัดชำระ ' + it.execMeta.otherCount + ' แถว' : ''}
                        </span>
                      )}
                      {it.status === 'ready' && !it.execMeta && (
                        <span style={{ color: 'var(--good)' }}>
                          ✓ {it.mangoKind === 'detail' ? 'OF 9.3 (รายรายการ)'
                            : it.mangoKind === 'header' ? 'OF ยอดรวม (PV/สถานะจ่าย)'
                            : 'พร้อม'}{it.ym ? ' · ' + fcFmtMonth(it.ym) : ''}{it.deptSaved ? ' · เก็บตารางผู้ถือบัตร ' + it.deptSaved + ' รายการ' : ''}
                        </span>
                      )}
                      {it.status === 'error' && <span style={{ color: 'var(--bad)' }} title={it.error}>✕ {it.error}</span>}
                    </td>
                    <td style={{ textAlign: 'right' }}>
                      <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>
        )}

        <div style={{ marginTop: 12, fontSize: 11.5, color: 'var(--ink-500)', lineHeight: 1.7 }}>
          • นำเข้าไฟล์เดิมซ้ำได้ — ระบบทับรายการเดิมของรอบนั้น (ยึด{!isBank ? 'เลขที่ OF + ลำดับรายการ' : (isExec ? 'วันที่ + ร้านค้า + ยอด' : 'เลขใบกำกับ')}) ไม่เพิ่มซ้ำ<br />
          • การจับคู่ที่เคยยืนยัน/ทำเองไว้ และสถานะ "ไม่ต้องตั้ง" จะถูกเก็บไว้เหมือนเดิม
          {!isBank && <><br />• รอบของ OF อ่านจากเลขเอกสาร (OF<b>2607</b>00180 = ก.ค. 2569) — ไฟล์เดียวมีหลายรอบก็นำเข้าได้ทีเดียว</>}
        </div>
      </Modal>
    );
  }

  /* ── UI: modal ระบุแผนกที่ใช้บัตร ────────────────────────────────────
   *   รายการไหนยังไม่มีแผนก ระบบถามเอง — เปิดขึ้นตอนนำเข้าไฟล์ธนาคารเสร็จ และตอนกด "ไฟล์ DATA"
   *   ติ๊กหลายรายการ / ทั้งบัตร / ทั้งหมด แล้วใส่แผนกทีเดียว · หรือพิมพ์ที่ช่องท้ายแถวทีละรายการ
   *   "OF ตั้งที่" = ฝ่ายที่ตั้งเบิกรายการนั้นใน Mango — เป็นตัวช่วย ผู้ใช้เป็นคนยืนยันเอง
   *   แถวที่แสดงเลือกจากสถานะ "ก่อนแก้" → พิมพ์แล้วแถวไม่หายไปจากมือ */
  function FcDeptModal({ rows, pairOf, options, ym, thenExport, onClose, onSkip, onSave }) {
    const uniq = (arr) => arr.filter((v, i) => v && arr.indexOf(v) === i);
    const items = useMemo(() => rows.map(t => {
      const p = pairOf[t.id];
      const ms = p ? (p.mangos || [p.mango]).filter(Boolean) : [];
      const ofDepts = uniq(ms.map(m => fcTrim(m.deptCode).toUpperCase()));
      const manual = fcDeptManual(t), auto = fcDeptAuto(t).toUpperCase();
      return {
        t, id: t.id, cur: manual || auto,
        src: manual ? 'ระบุเอง' : fcDeptLookup(t) ? 'ตาราง Dep.' : auto ? 'ไฟล์ธนาคาร' : '',
        ofDept: ofDepts.length === 1 ? ofDepts[0] : '',
        ofText: ofDepts.join(' / '),
        ofNo: uniq(ms.map(m => fcTrim(m.docNo))).join(', '),
      };
    }), [rows, pairOf]);
    const isMissing = (it) => !it.cur && it.t.status !== 'ignored';
    const isDiff = (it) => !!it.cur && !!it.ofDept && it.cur !== it.ofDept;
    const nMissing = items.filter(isMissing).length, nDiff = items.filter(isDiff).length;
    const [view, setView] = useState(nMissing ? 'missing' : nDiff ? 'diff' : 'all');
    const [draft, setDraft] = useState({});
    const [picked, setPicked] = useState({});
    const [bulk, setBulk] = useState('');
    const clean = (v) => String(v || '').toUpperCase().replace(/\s+/g, '').slice(0, 6);
    const valOf = (it) => Object.prototype.hasOwnProperty.call(draft, it.id) ? draft[it.id] : it.cur;
    const setMany = (list, valFor) => setDraft(prev => {
      const next = { ...prev };
      list.forEach(it => { next[it.id] = valFor(it); });
      return next;
    });

    const cardOf = (it) => fcTrim(it.t.plateNo) || fcTrim(it.t.driverName) || '(ไม่ระบุทะเบียน)';
    const shown = items.filter(it => view === 'missing' ? isMissing(it) : view === 'diff' ? isDiff(it) : true)
      .sort((a, b) => cardOf(a).localeCompare(cardOf(b), 'th') ||
        String(a.t.trnDate).localeCompare(String(b.t.trnDate)) || String(a.t.trnTime).localeCompare(String(b.t.trnTime)));
    const groups = [], gIdx = {};
    shown.forEach(it => {
      const k = cardOf(it) + '|' + fcTrim(it.t.driverName);
      (gIdx[k] || (gIdx[k] = groups[groups.push({ key: k, card: cardOf(it), driver: fcTrim(it.t.driverName), items: [] }) - 1])).items.push(it);
    });
    const pickedRows = shown.filter(it => picked[it.id]);
    const pick = (list, on) => setPicked(prev => {
      const next = { ...prev };
      list.forEach(it => { if (on) next[it.id] = true; else delete next[it.id]; });
      return next;
    });
    const allOn = (list) => list.length > 0 && list.every(it => picked[it.id]);
    const bulkVal = clean(bulk);
    const pickedWithOf = pickedRows.filter(it => it.ofDept);

    const changes = items.filter(it => valOf(it) !== it.cur).map(it => ({ t: it.t, dept: valOf(it) }));
    const leftMissing = items.filter(it => it.t.status !== 'ignored' && !valOf(it)).length;
    const chip = (key, label, n, color) => (
      <button key={key} onClick={() => { setView(key); setPicked({}); }}
        style={{ border: '1px solid ' + (view === key ? 'var(--brand-500)' : 'var(--line)'), cursor: 'pointer', borderRadius: 999,
          padding: '3px 11px', fontSize: 11.5, fontWeight: 700, fontFamily: 'inherit',
          background: view === key ? 'var(--brand-500)' : '#fff', color: view === key ? '#fff' : (n ? color : 'var(--ink-500)') }}>
        {label} ({n})
      </button>
    );
    const box = { width: 15, height: 15, cursor: 'pointer', margin: 0, verticalAlign: 'middle' };

    return (
      <Modal open maxWidth={960} onClose={onClose} title={'🏷️ ระบุแผนกที่ใช้บัตร — รอบ ' + fcFmtMonth(ym)}
        footer={<>
          <span style={{ marginRight: 'auto', fontSize: 12, color: leftMissing ? 'var(--warn)' : 'var(--good)' }}>
            {changes.length ? 'แก้ ' + changes.length + ' รายการ · ' : ''}
            {leftMissing ? 'ยังไม่ระบุแผนกอีก ' + leftMissing + ' รายการ' : 'ระบุแผนกครบทุกรายการแล้ว'}
          </span>
          <button className="btn btn-ghost" onClick={onClose}>ยกเลิก</button>
          {thenExport && <button className="btn btn-ghost" onClick={onSkip} title="ออกไฟล์เลย — รายการที่ยังไม่มีแผนกจะขึ้นเป็น &quot;ไม่ระบุ&quot;">ดาวน์โหลดโดยยังไม่ระบุ</button>}
          <button className="btn btn-primary" disabled={!changes.length && !thenExport} onClick={() => onSave(changes, !!thenExport)}>
            {thenExport ? 'บันทึกแล้วดาวน์โหลด' : 'บันทึก'}
          </button>
        </>}>
        <div style={{ marginBottom: 10, padding: '8px 12px', borderRadius: 8, background: 'var(--ink-50)', fontSize: 12, color: 'var(--ink-700)', lineHeight: 1.7 }}>
          {thenExport && nMissing > 0 && <div style={{ fontWeight: 700, color: 'var(--warn)' }}>มี {nMissing} รายการยังไม่มีแผนก — ระบุก่อนออกไฟล์ ยอดรายฝ่ายจะได้ไม่ไปกองที่ "ไม่ระบุ"</div>}
          ระบุว่าแต่ละรายการ <b>แผนกไหนเบิกบัตรไปใช้</b> — ติ๊กหลายรายการ (หรือติ๊กหัวบัตร = ทั้งบัตร) แล้วใส่แผนกทีเดียวได้
          · ช่อง "OF ตั้งที่" คือฝ่ายที่ตั้งเบิกรายการนั้นใน Mango ใช้เป็นตัวช่วยได้
          · บัตรที่มีชื่อผู้ถือและยังไม่เคยมีแผนก ระบบจะจำไว้ใช้เดือนถัดไป ส่วนบัตร VIP ถามใหม่ทุกรอบ
        </div>

        <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 8 }}>
          {chip('missing', 'ยังไม่มีแผนก', nMissing, 'var(--warn)')}
          {chip('diff', 'แผนกไม่ตรงกับที่ตั้ง OF', nDiff, 'var(--bad)')}
          {chip('all', 'ทั้งหมด', items.length, 'var(--ink-700)')}
        </div>

        {/* แถบใส่แผนกให้รายการที่ติ๊ก */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 8, padding: '7px 10px', borderRadius: 8,
          border: '1px solid ' + (pickedRows.length ? 'var(--brand-500)' : 'var(--line)'),
          background: pickedRows.length ? 'color-mix(in oklch, var(--brand-500) 7%, #fff)' : '#fff' }}>
          <span style={{ fontSize: 12, fontWeight: 700, color: pickedRows.length ? 'var(--brand-700)' : 'var(--ink-500)' }}>
            {pickedRows.length ? 'ติ๊กไว้ ' + pickedRows.length + ' รายการ · ' + fmtMoney(pickedRows.reduce((s, it) => s + fcNum(it.t.amount), 0)) + ' บาท'
              : 'ติ๊กรายการที่จะใส่แผนกเดียวกัน'}
          </span>
          <input className="input" list="fc-dept-list" value={bulk} placeholder="แผนก" disabled={!pickedRows.length}
            onChange={e => setBulk(clean(e.target.value))}
            onKeyDown={e => { if (e.key === 'Enter' && bulkVal && pickedRows.length) { setMany(pickedRows, () => bulkVal); setPicked({}); } }}
            style={{ width: 92, fontSize: 12.5, padding: '3px 8px', fontWeight: 700 }} />
          <button className="btn btn-primary" disabled={!pickedRows.length || !bulkVal}
            onClick={() => { setMany(pickedRows, () => bulkVal); setPicked({}); }}>
            ใส่{bulkVal ? ' ' + bulkVal : 'แผนก'} ให้ที่ติ๊ก
          </button>
          <button className="btn btn-ghost" disabled={!pickedWithOf.length}
            title="ใส่แผนกของแต่ละรายการให้ตรงกับฝ่ายที่ตั้ง OF รายการนั้นใน Mango (เฉพาะรายการที่จับคู่ OF แล้ว)"
            onClick={() => { setMany(pickedWithOf, it => it.ofDept); setPicked({}); }}>
            ใช้ตามฝ่ายที่ตั้ง OF{pickedWithOf.length ? ' (' + pickedWithOf.length + ')' : ''}
          </button>
        </div>
        <datalist id="fc-dept-list">
          {options.map(o => <option key={o.code} value={o.code}>{o.name}</option>)}
        </datalist>

        <div style={{ maxHeight: '48vh', overflow: 'auto', border: '1px solid var(--line)', borderRadius: 8 }}>
          <table style={{ width: '100%', borderCollapse: 'collapse' }}>
            <thead><tr>
              <th style={fcTh('center', 30)}>
                <input type="checkbox" style={box} checked={allOn(shown)} onChange={e => pick(shown, e.target.checked)} title="ติ๊กทั้งหมดที่แสดงอยู่" />
              </th>
              <th style={fcTh('left', 82)}>วันที่เติม</th>
              <th style={fcTh('left')}>ปั๊ม</th>
              <th style={fcTh('right', 84)}>ยอดเงิน</th>
              <th style={fcTh('left', 190)}>OF ตั้งที่</th>
              <th style={fcTh('left', 150)}>แผนกที่ใช้บัตร</th>
            </tr></thead>
            <tbody>
              {groups.length === 0 && (
                <tr><td colSpan={6} style={{ padding: 28, textAlign: 'center', color: 'var(--ink-500)', fontSize: 12.5 }}>
                  {view === 'missing' ? '✅ ทุกรายการมีแผนกแล้ว' : view === 'diff' ? '✅ แผนกตรงกับที่ตั้ง OF ทุกรายการ' : 'ไม่มีรายการ'}
                </td></tr>
              )}
              {groups.map(g => (
                <React.Fragment key={g.key}>
                  <tr style={{ background: 'var(--ink-50)' }}>
                    <td style={fcTd('center')}>
                      <input type="checkbox" style={box} checked={allOn(g.items)} onChange={e => pick(g.items, e.target.checked)} title="ติ๊กทั้งบัตรนี้" />
                    </td>
                    <td colSpan={2} style={fcTd('left', { fontWeight: 700, color: 'var(--ink-800)' })}>
                      {g.card}{g.driver ? ' · ' + g.driver : ''}
                      <span style={{ fontWeight: 500, color: 'var(--ink-500)' }}> — {g.items.length} รายการ</span>
                    </td>
                    <td style={fcTd('right', { fontWeight: 700 })}>{fmtMoney(g.items.reduce((s, it) => s + fcNum(it.t.amount), 0))}</td>
                    <td colSpan={2} style={fcTd('left')} />
                  </tr>
                  {g.items.map(it => {
                    const v = valOf(it), edited = v !== it.cur;
                    return (
                      <tr key={it.id} style={{ background: picked[it.id] ? 'color-mix(in oklch, var(--brand-500) 6%, #fff)' : undefined }}>
                        <td style={fcTd('center')}>
                          <input type="checkbox" style={box} checked={!!picked[it.id]} onChange={e => pick([it], e.target.checked)} />
                        </td>
                        <td style={fcTd('left')}>{fcDMY(it.t.trnDate, '/')}</td>
                        <td style={fcTd('left', { maxWidth: 260 })} title={it.t.pp20Name || it.t.merchantName}>{it.t.pp20Name || it.t.merchantName}</td>
                        <td style={fcTd('right')}>{fmtMoney(fcNum(it.t.amount))}</td>
                        <td style={fcTd('left', { color: 'var(--ink-600)' })} title={it.ofNo}>
                          {it.ofText
                            ? <>
                                <b style={{ color: v && it.ofDept && v !== it.ofDept ? 'var(--bad)' : 'var(--ink-800)' }}>{it.ofText}</b>
                                <span style={{ color: 'var(--ink-400)' }}> · {it.ofNo}</span>
                              </>
                            : <span style={{ color: 'var(--ink-400)' }}>{it.t.status === 'ignored' ? 'ไม่ต้องตั้ง' : 'ยังไม่ตั้ง OF'}</span>}
                        </td>
                        <td style={fcTd('left', { overflow: 'visible' })}>
                          <input className="input" list="fc-dept-list" value={v} placeholder="—"
                            onChange={e => { const nv = clean(e.target.value); setDraft(prev => ({ ...prev, [it.id]: nv })); }}
                            style={{ width: 74, fontSize: 12, padding: '2px 7px', fontWeight: 700,
                              borderColor: !v ? 'var(--warn)' : edited ? 'var(--brand-500)' : undefined }} />
                          <span style={{ marginLeft: 6, fontSize: 10.5, color: edited ? 'var(--brand-600)' : 'var(--ink-400)' }}>
                            {edited ? (v ? 'แก้แล้ว' : 'ล้าง') : it.src}
                          </span>
                        </td>
                      </tr>
                    );
                  })}
                </React.Fragment>
              ))}
            </tbody>
          </table>
        </div>
      </Modal>
    );
  }

  /* ── UI: modal ล้างข้อมูลที่นำเข้า ───────────────────────────────────
   *   ใช้ตอนวางไฟล์ผิดแท็บ/ผิดรอบ — เลือกได้ว่าจะล้างฝั่งไหน และแค่รอบนี้หรือทุกรอบ
   *   ต้องติ๊กยืนยันอีกชั้นก่อนปุ่มล้างจะกดได้ (ลบแล้วเรียกคืนไม่ได้ ต้องนำเข้าไฟล์ใหม่) */
  function FcPurgeModal({ program, ym, progTxns, progMangos, onClose, onPurge }) {
    const prog = fcProgInfo(program);
    const [bank, setBank] = useState(false);
    const [mango, setMango] = useState(true);
    const [allYm, setAllYm] = useState(false);
    const [sure, setSure] = useState(false);
    const inScope = (r) => !allYm ? r.ym === ym : true;
    const nBank = progTxns.filter(inScope).length;
    const nMango = progMangos.filter(inScope).length;
    const total = (bank ? nBank : 0) + (mango ? nMango : 0);
    const box = (checked, on, label, sub) => (
      <label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', padding: '8px 10px', borderRadius: 8,
        border: '1px solid ' + (checked ? 'var(--brand-500)' : 'var(--line)'),
        background: checked ? 'color-mix(in oklch, var(--brand-500) 7%, #fff)' : '#fff', cursor: 'pointer' }}>
        <input type="checkbox" checked={checked} onChange={e => on(e.target.checked)} style={{ marginTop: 2 }} />
        <span>
          <span style={{ fontSize: 12.5, fontWeight: 700 }}>{label}</span>
          <div style={{ fontSize: 11, color: 'var(--ink-500)' }}>{sub}</div>
        </span>
      </label>
    );
    return (
      <Modal open maxWidth={560} onClose={onClose} title={'🧹 ล้างข้อมูลที่นำเข้า — ' + prog.label}
        footer={<>
          <span style={{ marginRight: 'auto', fontSize: 12, color: total ? 'var(--bad)' : 'var(--ink-500)' }}>
            {total ? 'จะลบทั้งหมด ' + total + ' รายการ' : 'ยังไม่ได้เลือกอะไร'}
          </span>
          <button className="btn btn-ghost" onClick={onClose}>ยกเลิก</button>
          <button className="btn" disabled={!total || !sure}
            style={{ background: total && sure ? 'var(--bad)' : undefined, color: total && sure ? '#fff' : undefined,
              borderColor: total && sure ? 'var(--bad)' : undefined }}
            onClick={() => onPurge({ bank, mango, allYm })}>
            ล้างข้อมูล
          </button>
        </>}>
        <div style={{ fontSize: 12.5, color: 'var(--ink-600)', marginBottom: 12, lineHeight: 1.7 }}>
          ใช้ตอนนำเข้าไฟล์ผิดแท็บหรือผิดรอบ — ล้างของที่ผิดออกก่อน แล้วค่อยนำเข้าใหม่ให้ถูกที่<br />
          <b>ล้างเฉพาะ {prog.label}</b> เท่านั้น · ชุดบัตรอีกฝั่งไม่ถูกแตะ
        </div>
        <div style={{ display: 'grid', gap: 8, marginBottom: 12 }}>
          {box(mango, setMango, 'ใบ OF จาก Mango (' + nMango + ' รายการ)', 'ลบใบ OF ที่นำเข้ามา — การจับคู่ที่ผูกไว้จะถูกปลดด้วย')}
          {box(bank, setBank, 'บิลจากธนาคาร (' + nBank + ' รายการ)', 'ลบรายการเติม/รูดบัตรที่นำเข้ามา รวมสถานะและหมายเหตุที่บันทึกไว้')}
          {box(allYm, setAllYm, 'ทุกรอบของชุดบัตรนี้', allYm ? 'กำลังจะล้างทุกเดือน' : 'ตอนนี้ล้างเฉพาะรอบ ' + fcFmtMonth(ym))}
        </div>
        <label style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12.5, color: 'var(--bad)', fontWeight: 700 }}>
          <input type="checkbox" checked={sure} onChange={e => setSure(e.target.checked)} />
          เข้าใจแล้วว่าลบถาวร ต้องนำเข้าไฟล์ใหม่เท่านั้นถึงจะได้ข้อมูลกลับมา
        </label>
      </Modal>
    );
  }

  /* ── UI: modal จับคู่เอง ─────────────────────────────────────────── */
  function FcLinkModal({ txn, mangoOnly, usedPairs, onClose, onLink }) {
    const [q, setQ] = useState('');
    const [onlySame, setOnlySame] = useState(false);
    // ใบที่ "มีเจ้าของแล้ว" ก็ต้องเลือกได้ — เคสจับคู่สลับใบกัน (ยอดเท่ากันแต่คนละใบ)
    const holderOf = useMemo(() => {
      const map = {};
      (usedPairs || []).forEach(p => (p.mangos || [p.mango]).forEach(m => { if (m) map[m.id] = p.txn; }));
      return map;
    }, [usedPairs]);
    // ใบที่รายการนี้จับคู่อยู่ตอนนี้ (มีได้หลายใบ — เคสหลายใบ OF ต่อหนึ่งรายการบิล)
    const curIds = useMemo(() => {
      const p = (usedPairs || []).find(x => x.txn.id === txn.id);
      return p ? (p.mangos || [p.mango]).filter(Boolean).map(m => m.id) : [];
    }, [usedPairs, txn]);
    const isChange = curIds.length > 0;
    // ติ๊กเลือกได้หลายใบ — ยอดบิลหนึ่งรายการอาจถูกตั้งเป็น OF หลายใบ (เช่น ค่าตั๋ว + ส่วนต่างอัตราแลกเปลี่ยน)
    const [sel, setSel] = useState(() => { const s = {}; curIds.forEach(id => { s[id] = true; }); return s; });
    const toggle = (id) => setSel(s => { const n = Object.assign({}, s); if (n[id]) delete n[id]; else n[id] = true; return n; });
    const list = useMemo(() => {
      const kw = fcTxtNorm(q);
      const used = [];
      (usedPairs || []).forEach(p => (p.mangos || [p.mango]).forEach(m => { if (m) used.push(m); }));
      const seen = {};
      const rows = mangoOnly.concat(used).filter(m => m && !seen[m.id] && (seen[m.id] = 1));
      const dv = m => Math.abs(fcNum(m.amount) - fcNum(txn.amount));
      const isCur = id => curIds.indexOf(id) >= 0;
      rows.sort((a, b) =>
        ((isCur(b.id) ? 1 : 0) - (isCur(a.id) ? 1 : 0)) ||          // ใบที่ใช้อยู่ขึ้นก่อน
        (dv(a) - dv(b)) ||                                          // ยอดใกล้ที่สุด
        ((holderOf[a.id] ? 1 : 0) - (holderOf[b.id] ? 1 : 0)) ||    // ใบว่างก่อนใบที่มีเจ้าของ
        String(a.docNo || '').localeCompare(String(b.docNo || '')));
      return rows.filter(m =>
        (sel[m.id] || !onlySame || dv(m) < 0.01) &&
        (sel[m.id] || !kw || (fcTxtNorm(m.itemName) + fcTxtNorm(m.vendor) + fcTxtNorm(m.docNo) + fcTxtNorm(m.docRemark)).indexOf(kw) >= 0));
    }, [q, onlySame, mangoOnly, usedPairs, holderOf, curIds, sel, txn]);

    const picked = useMemo(() => list.filter(m => sel[m.id]), [list, sel]);
    const pickSum = picked.reduce((s, m) => s + fcNum(m.amount), 0);
    const gap = fcNum(txn.amount) - pickSum;
    const fits = Math.abs(gap) < 0.01;
    const same = curIds.length === picked.length && picked.every(m => curIds.indexOf(m.id) >= 0);

    return (
      <Modal open maxWidth={820} onClose={onClose}
        title={isChange ? '🔁 แก้ใบ OF ที่จับคู่ไว้ (เลือกได้หลายใบ)' : '🔗 จับคู่กับใบ OF ที่ตั้งใน Mango'}
        footer={<>
          <span style={{ marginRight: 'auto', fontSize: 12, lineHeight: 1.5 }}>
            {picked.length === 0
              ? <span style={{ color: 'var(--ink-500)' }}>ติ๊กเลือกใบ OF ที่จะจับคู่กับบิลนี้ (เลือกได้หลายใบ)</span>
              : <>
                  <b>เลือก {picked.length} ใบ · {fmtMoney(pickSum)}</b>
                  <span style={{ color: 'var(--ink-500)' }}> / บิล {fmtMoney(txn.amount)}</span>
                  <span style={{ color: fits ? 'var(--good)' : 'var(--warn)', fontWeight: 700 }}>
                    {fits ? '  ✓ ยอดตรงพอดี' : (gap > 0 ? '  ยังขาด ' + fmtMoney(gap) : '  เกิน ' + fmtMoney(-gap))}
                  </span>
                </>}
          </span>
          <button className="btn btn-ghost" onClick={onClose}>ยกเลิก</button>
          <button className="btn btn-primary" disabled={picked.length === 0 || same}
            onClick={() => { onLink(txn, picked); onClose(); }}>
            {picked.length > 1 ? 'จับคู่ ' + picked.length + ' ใบนี้' : 'จับคู่ใบนี้'}
          </button>
        </>}>
        <div className="card" style={{ padding: '10px 14px', marginBottom: 12, background: 'var(--ink-50)' }}>
          <div style={{ fontSize: 12.5, fontWeight: 700 }}>
            {[txn.trnDate, txn.plateNo || txn.cardNo, txn.driverName].filter(Boolean).join(' · ')}
          </div>
          <div style={{ fontSize: 12, color: 'var(--ink-600)', marginTop: 2 }}>
            {[txn.pp20Name || txn.merchantName, txn.productName].filter(Boolean).join(' · ')}
            {' · '}<b>{fmtMoney(txn.amount)} บาท</b>
            {txn.invoiceNo ? ' · ใบกำกับ ' + txn.invoiceNo : ''}
          </div>
        </div>
        <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 10 }}>
          <input className="input" placeholder="ค้นหา (รายละเอียด / ผู้ขาย / เลขที่เอกสาร / หมายเหตุ)" value={q}
            onChange={e => setQ(e.target.value)} style={{ flex: 1, fontSize: 12.5 }} />
          <label style={{ display: 'flex', gap: 5, alignItems: 'center', fontSize: 12, whiteSpace: 'nowrap', cursor: 'pointer' }}>
            <input type="checkbox" checked={onlySame} onChange={e => setOnlySame(e.target.checked)} />
            เฉพาะยอดตรงกัน
          </label>
        </div>
        <div style={{ maxHeight: 340, overflow: 'auto', border: '1px solid var(--line)', borderRadius: 10 }}>
          <table className="tbl" style={{ width: '100%', fontSize: 12 }}>
            <thead><tr>
              <th style={{ width: 30 }}></th>
              <th style={{ textAlign: 'left' }}>วันที่ / เลขที่ OF</th>
              <th style={{ textAlign: 'left' }}>รายละเอียด</th>
              <th style={{ textAlign: 'left' }}>ผู้ขาย</th>
              <th style={{ textAlign: 'right' }}>ยอด</th>
            </tr></thead>
            <tbody>
              {list.length === 0 && <tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--ink-400)', padding: 18 }}>ไม่มีใบ OF ที่ตรงเงื่อนไข</td></tr>}
              {list.slice(0, 200).map(m => {
                const exact = Math.abs(fcNum(m.amount) - fcNum(txn.amount)) < 0.01;
                const cur = curIds.indexOf(m.id) >= 0;
                const owner = !cur ? holderOf[m.id] : null;
                const on = !!sel[m.id];
                return (
                  <tr key={m.id} onClick={() => toggle(m.id)} style={{ cursor: 'pointer',
                    background: on ? 'color-mix(in oklch, var(--brand-500) 9%, #fff)' : undefined }}>
                    <td style={{ textAlign: 'center' }}>
                      <input type="checkbox" checked={on} onChange={() => toggle(m.id)} onClick={e => e.stopPropagation()} />
                    </td>
                    <td style={{ whiteSpace: 'nowrap', fontSize: 11 }}>
                      {m.eventDate || fcFmtMonth(m.ym)}
                      <div style={{ color: 'var(--ink-400)' }}>{m.docNo || ''}</div>
                    </td>
                    <td style={{ maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={m.itemName}>
                      {m.itemName || '—'}
                      {cur && <div style={{ fontSize: 10.5, color: 'var(--brand-600)', fontWeight: 700 }}>● ใบที่จับคู่อยู่ตอนนี้</div>}
                      {owner && (
                        <div style={{ fontSize: 10.5, color: 'var(--warn)' }} title="เลือกใบนี้ = ดึงมาจากรายการนั้น">
                          ⇄ ใบนี้คู่อยู่กับ {owner.trnDate} · {owner.plateNo || owner.merchantName || owner.driverName || owner.cardNo} · {fmtMoney(owner.amount)}
                        </div>
                      )}
                    </td>
                    <td style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={m.vendor}>{m.vendor || '—'}</td>
                    <td className="num" style={{ color: exact ? 'var(--good)' : 'var(--ink-700)', fontWeight: exact ? 700 : 500 }}>{fmtMoney(m.amount)}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </Modal>
    );
  }

  /* ── หน้าหลัก ────────────────────────────────────────────────────── */
  function FleetCardPage({ toast }) {
    const [txns, setTxns] = useState([]);
    const [mangos, setMangos] = useState([]);
    const [program, setProgram] = useState(FC_PROGRAMS[0].key);   // ชุดบัตรที่กำลังดู
    const [loading, setLoading] = useState(true);
    const [saving, setSaving] = useState(false);
    const [ym, setYm] = useState('');
    const [tab, setTab] = useState('recon');       // recon | txns | summary
    const [importKind, setImportKind] = useState(null);   // 'bank' | 'mango' | null
    const [linkTxn, setLinkTxn] = useState(null);
    const [purgeOpen, setPurgeOpen] = useState(false);   // หน้าต่างล้างข้อมูลที่นำเข้า
    const [q, setQ] = useState('');
    const [reconFilter, setReconFilter] = useState('pending'); // pending | unmatched | over | matched | all
    const [crossMonth, setCrossMonth] = useState(true);    // ยอมจับคู่กับ OF รอบข้างเคียง (±1 เดือน)
    const [deptAsk, setDeptAsk] = useState(null);          // หน้าต่างระบุแผนก: null | { thenExport }
    const askDeptAfterImport = useRef(false);              // นำเข้าไฟล์ธนาคารเสร็จ → ถามแผนกที่ยังขาดทันที

    const isExecProg = program === 'exec';   // บัตรเครดิตผู้บริหาร — ไม่มีลิตร/ปั๊ม/ทะเบียน
    const readOnly = !(window.WTPAuth && window.WTPAuth.can('canEdit'));
    const say = (m) => { if (toast) toast(m); };

    /* โหลดข้อมูลจาก server (ตารางนำเข้า — ไม่ได้อยู่ใน snapshot /api/all ที่ poll) */
    const reload = () => {
      if (!window.WTPData || !window.WTPData.fetchSheetRows) { setLoading(false); return Promise.resolve(); }
      setLoading(true);
      return Promise.all([
        window.WTPData.fetchSheetRows(FC_TXN),
        window.WTPData.fetchSheetRows(FC_MNG),
      ]).then(([a, b]) => {
        setTxns(a || []); setMangos(b || []); setLoading(false);
      }).catch(err => {
        console.warn('[FleetCard] โหลดข้อมูลไม่สำเร็จ:', err && err.message);
        setLoading(false);
      });
    };
    useEffect(() => { reload(); }, []);

    /* ── ข้อมูลของ "ชุดบัตร" ที่เลือกอยู่ (แท็บบนสุด) ── */
    const progTxns = useMemo(() => txns.filter(r => fcProgOf(r) === program), [txns, program]);
    const progMangos = useMemo(() => mangos.filter(r => fcProgOf(r) === program), [mangos, program]);
    const progCount = useMemo(() => {
      const c = {};
      FC_PROGRAMS.forEach(p => { c[p.key] = 0; });
      txns.forEach(r => { const k = fcProgOf(r); if (c[k] != null) c[k] += 1; });
      return c;
    }, [txns]);
    // เลขลูกค้า (CUSTOMER_ID) ที่เคยนำเข้าไว้ในชุดบัตร "อีกฝั่ง" — ใช้เตือนตอนลากไฟล์ผิดแท็บ
    const otherCustomerIds = useMemo(() => {
      const set = {};
      txns.forEach(r => { if (fcProgOf(r) !== program && r.customerId) set[r.customerId] = 1; });
      return Object.keys(set);
    }, [txns, program]);

    /* รอบเดือนที่มีข้อมูล (เฉพาะชุดบัตรนี้) */
    const months = useMemo(() => {
      const set = {};
      progTxns.forEach(r => { if (r.ym) set[r.ym] = true; });
      progMangos.forEach(r => { if (r.ym) set[r.ym] = true; });
      return Object.keys(set).sort().reverse();
    }, [progTxns, progMangos]);
    useEffect(() => {
      // ตั้งต้นที่ "รอบล่าสุดที่มีบิลธนาคาร" — รอบที่มีแต่ OF (ตั้งล่วงหน้า/รอบถัดไป) ยังไม่มีอะไรให้กระทบ
      // สลับแท็บชุดบัตรแล้วรอบเดิมไม่มีข้อมูล → เด้งไปรอบล่าสุดของชุดนั้นแทน
      if (!months.length) { if (ym) setYm(''); return; }
      if (!ym || months.indexOf(ym) < 0) {
        const withBill = months.find(m => progTxns.some(t => t.ym === m));
        setYm(withBill || months[0]);
      }
    }, [months, ym, progTxns]);

    // แถวในรอบนี้ · แยก "ค่าใช้จ่ายที่ต้องตั้ง OF" ออกจากยอดยกมา/ตัดชำระ/เงินคืน (บัตรเครดิต)
    const monthAll = useMemo(() => progTxns.filter(r => r.ym === ym), [progTxns, ym]);
    const monthTxns = useMemo(() => monthAll.filter(r => !r.txnType || r.txnType === 'expense'), [monthAll]);
    const monthOther = useMemo(() => monthAll.filter(r => r.txnType && r.txnType !== 'expense'), [monthAll]);
    const monthMangos = useMemo(() => progMangos.filter(r => r.ym === ym), [progMangos, ym]);
    // กองที่เอาไปจับคู่ = OF รอบนี้ + รอบข้างเคียง ±1 เดือน (เติมสิ้นเดือนแล้วไปตั้ง OF รอบถัดไป
    //   เป็นเรื่องปกติ) — รอบข้างเคียงมีแต้มลบ และไม่ถูกนับเป็น "ตั้งเกิน" ของรอบนี้
    /* ใบ OF ที่ "ถูกบิลของรอบอื่นจับไปแล้ว" (ยืนยัน/จับเอง) — id เก็บไว้ที่ txn.mangoId
     *   ต้องกันไว้ 2 ทาง: ห้ามเอามาจับซ้ำกับบิลรอบนี้ และห้ามขึ้นเป็น "ตั้งเกิน" ในรอบตัวเอง
     *   (เคสจริง: OF260800012 เลข OF เป็นรอบ ส.ค. แต่ค่าน้ำมันอยู่ในบิล ก.ค. — กระทบจบไปแล้ว) */
    const linkedElsewhere = useMemo(() => {
      const map = {};
      progTxns.forEach(t => {
        if (t.ym === ym) return;                       // ของรอบนี้ให้เครื่องจับคู่จัดการตามปกติ
        String(t.mangoId || '').split(',').map(s => s.trim()).filter(Boolean)
          .forEach(id => { map[id] = t.ym; });
      });
      return map;
    }, [progTxns, ym]);

    const mangoPool = useMemo(() => {
      const free = (rows) => rows.filter(r => !linkedElsewhere[r.id]);
      if (!ym || !crossMonth) return free(monthMangos);
      const [y, m] = ym.split('-').map(Number);
      const shift = (d) => {
        const dt = new Date(Date.UTC(y, m - 1 + d, 1));
        return dt.getUTCFullYear() + '-' + String(dt.getUTCMonth() + 1).padStart(2, '0');
      };
      const near = { [ym]: 1, [shift(-1)]: 1, [shift(1)]: 1 };
      return free(progMangos.filter(r => near[r.ym]));
    }, [progMangos, monthMangos, ym, crossMonth, linkedElsewhere]);
    const recon = useMemo(() => fcReconcile(monthTxns, mangoPool, { ym }), [monthTxns, mangoPool, ym]);
    const stats = useMemo(() => fcSummarize(monthTxns, monthMangos, recon), [monthTxns, monthMangos, recon]);

    /* ── แผนกที่ใช้บัตร: รายการไหนยังไม่มี ต้องถามผู้ใช้ (บัตร VIP = บัตรของฝ่ายการเงินที่แผนกอื่นเบิกไปใช้) ── */
    const pairOfTxn = useMemo(() => {
      const map = {};
      recon.pairs.forEach(p => { map[p.txn.id] = p; });
      return map;
    }, [recon]);
    const deptMissing = useMemo(() => isExecProg ? []
      : monthTxns.filter(t => t.status !== 'ignored' && !fcDeptOf(t)), [monthTxns, isExecProg]);
    useEffect(() => {
      if (!askDeptAfterImport.current) return;
      askDeptAfterImport.current = false;
      if (deptMissing.length && !readOnly) setDeptAsk({});
    }, [deptMissing]);

    /* ── ข้ามรอบ: บิลรอบนี้ไปใช้ OF รอบอื่น (ยืมมา) และ OF รอบนี้ไปตัดบิลรอบอื่น (ยืมไป) ──
     *   เกิดประจำเพราะเติม/รูดปลายเดือนแล้วธนาคารโพสต์ข้ามรอบ หรือคนตั้ง OF ช้าไปหนึ่งรอบ
     *   ต้องเห็นทั้งสองทิศถึงจะอธิบายผลต่าง "ยอดบิล vs ยอดตั้ง" ของรอบนั้นได้ */
    const cross = useMemo(() => {
      const empty = { inRows: [], outRows: [], nextOfIds: {} };
      if (!ym) return empty;
      const [y, m] = ym.split('-').map(Number);
      const shift = (d) => {
        const dt = new Date(Date.UTC(y, m - 1 + d, 1));
        return dt.getUTCFullYear() + '-' + String(dt.getUTCMonth() + 1).padStart(2, '0');
      };
      const nearOf = (k) => {
        const [yy, mm] = k.split('-').map(Number);
        const sh = (d) => { const dt = new Date(Date.UTC(yy, mm - 1 + d, 1)); return dt.getUTCFullYear() + '-' + String(dt.getUTCMonth() + 1).padStart(2, '0'); };
        return { [k]: 1, [sh(-1)]: 1, [sh(1)]: 1 };
      };
      // ยืมมา: คู่ของรอบนี้ที่ไปใช้ OF ของรอบอื่น
      const inRows = recon.pairs.filter(p => (p.mango.ym || ym) !== ym);
      // ยืมไป: ลองกระทบรอบข้างเคียง แล้วดูว่าใช้ OF ของรอบนี้ไปกี่รายการ
      const outRows = []; const nextOfIds = {};
      [shift(-1), shift(1)].forEach(nym => {
        const nTxns = progTxns.filter(r => r.ym === nym && (!r.txnType || r.txnType === 'expense'));
        if (!nTxns.length) return;
        const nPool = progMangos.filter(r => nearOf(nym)[r.ym]);
        const nRec = fcReconcile(nTxns, nPool, { ym: nym });
        nRec.pairs.forEach(p => {
          (p.mangos || [p.mango]).forEach(mm2 => {
            if ((mm2.ym || '') !== ym) return;
            outRows.push({ mango: mm2, txn: p.txn, ym: nym });
            nextOfIds[mm2.id] = nym;      // OF ใบนี้ "รอตัด" อยู่ในบิลรอบ nym
          });
        });
      });
      return { inRows, outRows, nextOfIds };
    }, [recon, progTxns, progMangos, ym]);

    /* ── ยอดสะสมทั้งปี — ข้ามรอบไปมาแล้ว "รวมทั้งปี" ต้องไม่มีอะไรตกหล่น ── */
    const yearRows = useMemo(() => {
      const year = String(ym || '').slice(0, 4);
      if (!year) return [];
      const nearOf = (k) => {
        const [yy, mm] = k.split('-').map(Number);
        const sh = (d) => { const dt = new Date(Date.UTC(yy, mm - 1 + d, 1)); return dt.getUTCFullYear() + '-' + String(dt.getUTCMonth() + 1).padStart(2, '0'); };
        return { [k]: 1, [sh(-1)]: 1, [sh(1)]: 1 };
      };
      const out = [];
      months.filter(m => m.indexOf(year + '-') === 0).slice().sort().forEach(m => {
        const t = progTxns.filter(r => r.ym === m && (!r.txnType || r.txnType === 'expense'));
        if (!t.length) return;
        const pool = progMangos.filter(r => nearOf(m)[r.ym]);
        const rc = fcReconcile(t, pool, { ym: m });
        const st = fcSummarize(t, progMangos.filter(r => r.ym === m), rc);
        out.push({ ym: m, bankAmt: st.bankAmt, bankCount: st.bankCount,
          matchedAmt: st.matchedAmt, matchedCount: st.matchedCount,
          pendingAmt: st.pendingAmt + st.shortfallAmt, pendingCount: st.pendingCount + st.shortfallCount });
      });
      return out;
    }, [months, progTxns, progMangos, ym]);

    /* ── บันทึกลงฐานข้อมูล ── (writeTable = แทนทั้งตาราง → ส่งทุกเดือนไปด้วยเสมอ) */
    const persist = (nextTxns, nextMangos, okMsg) => {
      if (!window.WTPData || !window.WTPData.writeTable) return Promise.resolve();
      setSaving(true);
      const jobs = [];
      if (nextTxns) jobs.push(window.WTPData.writeTable(FC_TXN, nextTxns, r => r.id));
      if (nextMangos) jobs.push(window.WTPData.writeTable(FC_MNG, nextMangos, r => r.id));
      return Promise.all(jobs).then(() => {
        setSaving(false);
        if (okMsg) say(okMsg);
      }).catch(err => {
        setSaving(false);
        const msg = String((err && err.message) || err);
        say(/403|permission|สิทธิ/.test(msg) ? 'บันทึกไม่สำเร็จ — สิทธิ์ไม่พอ (ลบรายการเดิมต้องใช้สิทธิ์หัวหน้า)' : 'บันทึกไม่สำเร็จ: ' + msg);
        throw err;
      });
    };

    /* ── นำเข้าไฟล์ธนาคาร ── (เข้าชุดบัตรของแท็บที่เปิดอยู่เท่านั้น) */
    const commitBank = (rows, targetYm) => {
      const prevById = {};
      txns.forEach(r => { prevById[r.id] = r; });
      const seen = {};
      const imported = rows.map((r, i) => {
        const inv = fcDigits(r.invoiceNo);
        const base = program + '|' + (inv.length >= 8
          ? targetYm + '|' + inv
          : targetYm + '|' + fcHash([r.cardNo, r.trnDate, r.postDate, r.amount, r.merchantNo || r.merchantName, i].join('~')));
        let id = base, n = 1;
        while (seen[id]) { id = base + '#' + (++n); }       // กันเลขใบกำกับซ้ำในไฟล์เดียวกัน
        seen[id] = true;
        const prev = prevById[id] || {};
        const row = { ...r, id, ym: targetYm, cardProgram: program };
        if (!fcTrim(row.deptCode)) row.deptCode = fcDeptLookup(row) || row.deptCode;   // เติมแผนกจากตารางผู้ถือบัตร
        delete row._seq;
        // คงค่าที่ผู้ใช้เคยตั้งไว้ (จับคู่เอง / ไม่ต้องตั้ง / โน้ต)
        row.mangoId = prev.mangoId || null;
        row.matchMode = prev.matchMode || null;
        row.status = prev.status || null;
        row.note = prev.note || null;
        return row;
      });
      // แทนที่เฉพาะ "รอบนี้ของชุดบัตรนี้" — ชุดบัตรอีกฝั่งไม่ถูกแตะ
      const kept = txns.filter(r => !(fcProgOf(r) === program && r.ym === targetYm));
      const next = kept.concat(imported);
      setTxns(next);
      setYm(targetYm);
      setImportKind(null);
      askDeptAfterImport.current = !isExecProg;
      persist(next, null, 'นำเข้ารายการเติมน้ำมัน ' + imported.length + ' รายการ · ' +
        fcProgInfo(program).short + ' · ' + fcFmtMonth(targetYm));
    };

    /* ── นำเข้ารายงาน OF จาก Mango ──
     *   ไฟล์เดียวมีได้หลายรอบ (เลข OF บอกรอบ) → แทนที่เฉพาะรอบที่มีอยู่ในไฟล์
     *   id = <รอบ>|<เลข OF>|<ลำดับรายการ+hash> → นำเข้าซ้ำแล้วทับแถวเดิม การจับคู่ที่ยืนยันไว้ไม่หลุด */
    const commitMango = (rows, targetYms) => {
      const yms = Array.isArray(targetYms) ? targetYms.filter(Boolean) : [targetYms].filter(Boolean);
      const ymSet = {}; yms.forEach(y => { ymSet[y] = true; });
      const seen = {};
      const imported = rows.map(r => {
        const rowYm = r.ym || yms[0] || '';
        const base = program + '|' + rowYm + '|' + (r.docNo || 'NA') + '|' +
          fcHash([r.itemNo, r.amount, r.vendor, r.itemName, r.eventDate].join('~'));
        let id = base, n = 1;
        while (seen[id]) { id = base + '#' + (++n); }
        seen[id] = true;
        const moved = fcYmOverride(id);              // เคยย้ายรอบไว้เอง → ใช้รอบนั้น
        const row = { ...r, id, ym: moved || rowYm, cardProgram: program };
        delete row._seq;
        delete row.headerOnly;
        return row;
      });
      // เก็บรอบที่ไฟล์นี้ไม่ได้พูดถึง + ชุดบัตรอีกฝั่งไว้ทั้งหมด
      const gone = {}; fcDeletedOfIds().forEach(id => { gone[id] = true; });
      const skipped = imported.filter(r => gone[r.id]).length;
      const fresh = imported.filter(r => !gone[r.id]);          // ใบที่เคยลบทิ้ง ไม่ต้องดึงกลับมา
      // ★ แทนที่ตาม "รอบตั้งต้น" ของใบ (จากเลข OF) ไม่ใช่รอบที่แสดงอยู่ — ใบที่เคยกด "ย้ายรอบ"
      //   ไปอยู่รอบอื่น เดิมไม่ถูกเอาออก แล้วไฟล์ก็เติมใบเดียวกันเข้ามาอีก = ขึ้นซ้ำ 2 แถว
      //   (และกลับกัน: นำเข้าไฟล์ของรอบปลายทาง ใบที่ย้ายมาจะหายไปเฉยๆ)
      const freshId = {}; fresh.forEach(r => { freshId[r.id] = true; });
      const kept = mangos.filter(r => !freshId[r.id] && !(fcProgOf(r) === program && ymSet[fcMangoHomeYm(r)]));
      const next = kept.concat(fresh);
      // รายการ Mango ของรอบที่นำเข้าใหม่ถูกแทน → ล้างลิงก์เก่าที่ชี้ไป id ที่ไม่มีแล้ว
      //   (mangoId เก็บได้หลายใบคั่นด้วย , — ตัดเฉพาะใบที่หายไป ใบที่ยังอยู่คงการจับคู่ไว้)
      const validId = {}; next.forEach(m => { validId[m.id] = true; });
      const nextTxns = txns.map(t => {
        if (!t.mangoId) return t;
        const ids = String(t.mangoId).split(',').filter(Boolean);
        const left = ids.filter(id => validId[id]);
        if (left.length === ids.length) return t;
        return { ...t, mangoId: left.length ? left.join(',') : null, matchMode: left.length ? t.matchMode : null };
      });
      setMangos(next);
      setTxns(nextTxns);
      if (yms.length && !ymSet[ym]) setYm(yms[yms.length - 1]);
      setImportKind(null);
      persist(nextTxns, next, 'นำเข้ารายการ OF ' + fresh.length + ' รายการ' + (skipped ? ' (ข้ามใบที่เคยลบ ' + skipped + ')' : '') + ' · ' +
        fcProgInfo(program).short + ' · ' + yms.map(fcFmtMonth).join(', '));
    };

    /* ── จับคู่เอง / ยกเลิกจับคู่ / ทำเครื่องหมายไม่ต้องตั้ง ── */
    /* เลือกใบ OF ให้รายการหนึ่ง — ถ้าใบนั้นมีรายการอื่นถืออยู่ ต้องจัดการเจ้าของเดิมด้วย:
     *   รายการนี้มีใบเดิมอยู่แล้ว → สลับใบกันไปมา (เคสจับคู่สลับ ยอดเท่ากันคนละใบ)
     *   รายการนี้ยังไม่มีใบ       → เจ้าของเดิมถูกปลดกลับไปเป็น "ยังไม่ตั้ง OF" */
    const linkPair = (txn, sel) => {
      const list = (Array.isArray(sel) ? sel : [sel]).filter(Boolean);
      if (!list.length) return;
      const ids = list.map(m => m.id);
      const idSet = {}; ids.forEach(i => { idSet[i] = 1; });
      const rowsOf = (p) => (p.mangos || [p.mango]).filter(Boolean);
      const curPair = (recon.pairs || []).find(p => p.txn.id === txn.id);
      const prevIds = curPair ? rowsOf(curPair).map(m => m.id) : [];
      // บิลอื่นที่ถือใบที่เลือกอยู่ — ต้องปลดหรือสลับให้เรียบร้อย ไม่งั้นใบเดียวไปโผล่สองที่
      const holders = (recon.pairs || []).filter(p => p.txn.id !== txn.id && rowsOf(p).some(m => idSet[m.id]));
      const swap = holders.length === 1 && list.length === 1 && prevIds.length === 1 && rowsOf(holders[0]).length === 1;
      if (holders.length) {
        const who = holders.map(p => '  ' + [p.txn.trnDate, p.txn.plateNo || p.txn.merchantName || p.txn.driverName,
          fmtMoney(p.txn.amount) + ' บาท'].filter(Boolean).join(' · ')).join('\n');
        if (!window.confirm('ใบ OF ที่เลือกมีบิลอื่นจับคู่อยู่:\n' + who + '\n\n' + (swap
          ? 'จะสลับใบ OF ระหว่างสองรายการนี้ใช่ไหม?'
          : 'จะดึงใบมาที่รายการที่เลือกใช่ไหม?\n(รายการเดิมจะกลับไปหาคู่ใหม่ หรือขึ้นเป็น "ยังไม่ตั้ง OF")'))) return;
      }
      const next = txns.map(t => {
        if (t.id === txn.id) return { ...t, mangoId: ids.join(','), matchMode: 'manual' };
        if (!holders.some(p => p.txn.id === t.id)) return t;
        if (swap) return { ...t, mangoId: prevIds[0], matchMode: 'manual' };
        const left = String(t.mangoId || '').split(',').map(s => s.trim()).filter(x => x && !idSet[x]);
        return { ...t, mangoId: left.length ? left.join(',') : null, matchMode: left.length ? t.matchMode : null };
      });
      setTxns(next);
      persist(next, null, list.length > 1 ? ('จับคู่ ' + list.length + ' ใบเข้ากับบิลนี้แล้ว')
        : holders.length ? (swap ? 'สลับใบ OF แล้ว' : 'ย้ายใบ OF มาจับคู่แล้ว') : 'จับคู่แล้ว');
    };
    const unlinkPair = (txn) => {
      const next = txns.map(t => t.id === txn.id ? { ...t, mangoId: null, matchMode: null } : t);
      setTxns(next); persist(next, null, 'ยกเลิกการจับคู่แล้ว');
    };
    const toggleIgnore = (txn) => {
      const next = txns.map(t => t.id === txn.id
        ? { ...t, status: t.status === 'ignored' ? null : 'ignored' } : t);
      setTxns(next); persist(next, null, txn.status === 'ignored' ? 'นำกลับมานับใหม่' : 'ทำเครื่องหมาย "ไม่ต้องตั้ง"');
    };

    /* ── ขั้นตอนติดตามรายการค้าง ──
     *   ฝั่งบิล (ยังไม่ตั้ง OF): null → 'of_pending' (ตั้งใน Mango แล้ว รอ export มา) → จับคู่ได้เอง
     *   ฝั่ง OF (ตั้งเกิน):     null → 'over_confirmed' (เกินจริง รอตัดออก) → 'over_cancelled' (ตัดแล้ว) → ลบได้
     *                            หรือ 'over_ok' = ตรวจแล้วไม่ใช่ตั้งเกิน เก็บไว้ดูย้อนหลัง */
    const FC_TXN_STEP = { of_pending: 'ตั้ง OF แล้ว รอขึ้นระบบ', ignored: 'ไม่ต้องตั้ง' };
    const setTxnStatus = (txn, st) => {
      const next = txns.map(t => t.id === txn.id ? { ...t, status: st || null } : t);
      setTxns(next); persist(next, null, st ? ('ทำเครื่องหมาย "' + (FC_TXN_STEP[st] || st) + '"') : 'ล้างสถานะติดตาม');
    };
    const setTxnNote = (txn, note) => {
      const next = txns.map(t => t.id === txn.id ? { ...t, note: note || null } : t);
      setTxns(next); persist(next, null, 'บันทึกหมายเหตุแล้ว');
    };
    const FC_OF_STEP = { over_confirmed: 'ตั้งเกินจริง — รอตัดออก', over_cancelled: 'Mango ตัดออกแล้ว', over_ok: 'ตรวจแล้ว ไม่ใช่ตั้งเกิน' };
    const setMangoStatus = (mango, st) => {
      const next = mangos.map(m => m.id === mango.id ? { ...m, status: st || null } : m);
      setMangos(next); persist(null, next, st ? ('ทำเครื่องหมาย "' + (FC_OF_STEP[st] || st) + '"') : 'ล้างสถานะติดตาม');
    };
    const setMangoNote = (mango, note) => {
      const next = mangos.map(m => m.id === mango.id ? { ...m, note: note || null } : m);
      setMangos(next); persist(null, next, 'บันทึกหมายเหตุแล้ว');
    };
    // ลบรายการ OF ที่ Mango ตัดออกแล้วจริงๆ — ลบถาวรจากฐานข้อมูล (ต้องสิทธิ์ลบ)
    /* ลบใบที่ตัดจ่าย PV ไปแล้วออกทั้งชุด — พวกนี้กระทบจบตั้งแต่รอบก่อน ติดมาซ้ำเพราะ
     *   ไฟล์ OF ที่ export มาคร่อมหลายรอบ · ไม่ใช่งานค้างของรอบนี้ */
    const deletePaidClosed = (rows) => {
      const list = (rows || []).filter(m => m && m.id);
      if (!list.length) return;
      const total = list.reduce((s, m) => s + fcNum(m.amount), 0);
      const partialIn = list.filter(m => fcPayState(m) === 'partial');
      if (!window.confirm(
        'เอาใบที่เลือกออกจากระบบ ' + list.length + ' ใบ (' + fmtMoney(total) + ' บาท)?\n\n' +
        (partialIn.length
          ? '⚠ มี ' + partialIn.length + ' ใบที่ PV จ่ายไม่เต็มยอด (ยังมีบรรทัดที่ยังไม่ถูกตัด) — ตรวจให้แน่ก่อนลบ\n\n'
          : '') +
        list.slice(0, 6).map(m => '· ' + (m.docNo || '—') + ' ' + fmtMoney(m.amount) +
          ' (' + (m.pvNo || 'จ่ายแล้ว') + (fcPayState(m) === 'partial' ? ' · จ่ายบางส่วน' : '') + ')').join('\n') +
        (list.length > 6 ? '\n· … อีก ' + (list.length - 6) + ' ใบ' : '') +
        '\n\nใบที่จ่ายเต็มแล้วคือกระทบกับบิลรอบก่อนเรียบร้อย — เอาออกเพื่อไม่ให้ค้างในรอบนี้')) return;
      const ids = {}; list.forEach(m => { ids[m.id] = true; });
      const next = mangos.filter(m => !ids[m.id]);
      const nextTxns = txns.map(t => {
        const cur = String(t.mangoId || '').split(',').filter(Boolean);
        if (!cur.some(id => ids[id])) return t;
        const left = cur.filter(id => !ids[id]);
        return { ...t, mangoId: left.length ? left.join(',') : null, matchMode: left.length ? t.matchMode : null };
      });
      setMangos(next); setTxns(nextTxns); setSaving(true);
      fcRememberDeleted(list.map(m => m.id));
      window.WTPData.forceDeleteRows(FC_MNG, list.map(m => m.id))
        .then(() => persist(nextTxns, next, 'เอาใบที่จ่ายแล้วออก ' + list.length + ' ใบ'))
        .catch(err => {
          setSaving(false);
          const msg = String((err && err.message) || err);
          say(/403|permission|สิทธิ/.test(msg) ? 'ลบไม่ได้ — สิทธิ์ไม่พอ (ต้องเป็นหัวหน้า)' : 'ลบไม่สำเร็จ: ' + msg);
          reload();
        });
    };

    /* ล้างข้อมูลที่นำเข้า — ใช้ตอนวางไฟล์ผิดแท็บ/ผิดรอบ แล้วอยากเริ่มใหม่
     *   ลบเฉพาะสิ่งที่เลือก (บิลธนาคาร / ใบ OF · รอบนี้ หรือทุกรอบของชุดบัตรนี้) */
    const purgeRows = (opt) => {
      const scopeYm = opt.allYm ? null : ym;
      const inScope = (r) => fcProgOf(r) === program && (!scopeYm || r.ym === scopeYm);
      const delTxns = opt.bank ? txns.filter(inScope) : [];
      const delMangos = opt.mango ? mangos.filter(inScope) : [];
      if (!delTxns.length && !delMangos.length) { say('ไม่มีรายการให้ล้าง'); return; }
      const nextTxns = txns.filter(t => delTxns.indexOf(t) < 0)
        .map(t => {
          const ids = String(t.mangoId || '').split(',').filter(Boolean);
          if (!ids.length) return t;
          const goneIds = {}; delMangos.forEach(m => { goneIds[m.id] = true; });
          const left = ids.filter(id => !goneIds[id]);
          return left.length === ids.length ? t
            : { ...t, mangoId: left.length ? left.join(',') : null, matchMode: left.length ? t.matchMode : null };
        });
      const nextMangos = mangos.filter(m => delMangos.indexOf(m) < 0);
      setTxns(nextTxns); setMangos(nextMangos); setPurgeOpen(false); setSaving(true);
      const jobs = [];
      if (delTxns.length) jobs.push(window.WTPData.forceDeleteRows(FC_TXN, delTxns.map(t => t.id)));
      if (delMangos.length) jobs.push(window.WTPData.forceDeleteRows(FC_MNG, delMangos.map(m => m.id)));
      Promise.all(jobs)
        .then(() => persist(nextTxns, nextMangos,
          'ล้างข้อมูลแล้ว — บิล ' + delTxns.length + ' · ใบ OF ' + delMangos.length + ' รายการ'))
        .catch(err => {
          setSaving(false);
          const msg = String((err && err.message) || err);
          say(/403|permission|สิทธิ/.test(msg) ? 'ล้างไม่ได้ — สิทธิ์ไม่พอ (ต้องเป็นหัวหน้า)' : 'ล้างไม่สำเร็จ: ' + msg);
          reload();
        });
    };

    /* ย้ายใบ OF ไปนับในรอบอื่น — ใช้กับใบที่จริงๆ เป็นของรอบใบแจ้งหนี้ก่อน (จ่ายไปแล้ว)
     *   หรือของรอบถัดไป (กำลังจะจ่าย) · ย้ายแล้วรายการจะไปโผล่ในรอบนั้นแทน ไม่หายไปไหน */
    const moveMangoYm = (mango, delta) => {
      const to = fcYmShift(mango.ym, delta);
      if (!to) return;
      const next = mangos.map(m => m.id === mango.id ? { ...m, ym: to } : m);
      setMangos(next);
      fcRememberYm(mango.id, to);
      persist(null, next, 'ย้าย ' + (mango.docNo || 'รายการ') + ' ไปรอบ ' + fcFmtMonth(to) + ' แล้ว');
    };

    const deleteMangoRow = (mango) => {
      if (!window.confirm(
        'ลบรายการนี้ออกจากระบบถาวร?\n\n' +
        (mango.docNo ? mango.docNo + ' · ' : '') + fmtMoney(mango.amount) + ' บาท\n' +
        (mango.itemName || mango.vendor || '') + '\n\n' +
        '⚠ ถ้านำเข้าไฟล์ OF รอบนี้ซ้ำอีกครั้ง และใบนี้ยังอยู่ในไฟล์ รายการจะกลับมาใหม่')) return;
      const next = mangos.filter(m => m.id !== mango.id);
      const nextTxns = txns.map(t => {
        const ids = String(t.mangoId || '').split(',').filter(Boolean);
        if (ids.indexOf(mango.id) < 0) return t;
        const left = ids.filter(id => id !== mango.id);
        return { ...t, mangoId: left.length ? left.join(',') : null, matchMode: left.length ? t.matchMode : null };
      });
      setMangos(next); setTxns(nextTxns);
      setSaving(true);
      fcRememberDeleted([mango.id]);
      const jobs = [window.WTPData.forceDeleteRows(FC_MNG, [mango.id])];
      Promise.all(jobs)
        .then(() => persist(nextTxns, next, 'ลบรายการออกจากระบบแล้ว'))
        .catch(err => {
          setSaving(false);
          const msg = String((err && err.message) || err);
          say(/403|permission|สิทธิ/.test(msg) ? 'ลบไม่ได้ — สิทธิ์ไม่พอ (ต้องเป็นหัวหน้า)' : 'ลบไม่สำเร็จ: ' + msg);
          reload();
        });
    };
    // ยืนยันคู่เดียว (จากปุ่มในตาราง) — ล็อกไว้ไม่ให้ระบบจับใหม่เวลาข้อมูลขยับ
    const confirmOne = (pair) => {
      const ids = (pair.mangos || [pair.mango]).map(m => m.id).join(',');
      const next = txns.map(t => t.id === pair.txn.id ? { ...t, mangoId: ids, matchMode: 'confirmed' } : t);
      // จำคู่ชื่อร้าน (บัตร ↔ เจ้าหนี้) ไว้ใช้เดือนถัดไป
      try { (pair.mangos || [pair.mango]).forEach(m => fcRememberAlias(pair.txn.merchantName || pair.txn.pp20Name, m.vendor)); } catch (_) {}
      setTxns(next); persist(next, null, 'ยืนยันการจับคู่แล้ว');
    };
    // ยืนยันคู่อัตโนมัติทั้งหมดของรอบนี้ (ล็อกไว้ไม่ให้เปลี่ยนเวลาข้อมูลขยับ)
    const confirmAuto = () => {
      const autoPairs = recon.pairs.filter(p => p.how !== 'manual');
      if (!autoPairs.length) { say('ไม่มีคู่อัตโนมัติให้ยืนยัน'); return; }
      const map = {}; autoPairs.forEach(p => { map[p.txn.id] = (p.mangos || [p.mango]).map(m => m.id).join(','); });
      try { autoPairs.forEach(p => (p.mangos || [p.mango]).forEach(m => fcRememberAlias(p.txn.merchantName || p.txn.pp20Name, m.vendor))); } catch (_) {}
      const next = txns.map(t => map[t.id] ? { ...t, mangoId: map[t.id], matchMode: 'confirmed' } : t);
      setTxns(next); persist(next, null, 'ยืนยันการจับคู่ ' + autoPairs.length + ' รายการ');
    };

    /* ── ตารางที่แสดง ── */
    const pendingRows = recon.txnOnly.filter(t => t.status !== 'ignored');
    const ignoredRows = recon.txnOnly.filter(t => t.status === 'ignored');
    const kw = fcTxtNorm(q);
    const matchTxt = (t) => !kw || (fcTxtNorm([t.plateNo, t.driverName, t.merchantName, t.pp20Name,
      t.invoiceNo, t.cardNo, t.productName, t.deptCode].join(' ')).indexOf(kw) >= 0);

    const filteredTxns = useMemo(() => monthTxns.filter(matchTxt)
      .slice().sort((a, b) => String(a.trnDate).localeCompare(String(b.trnDate))), [monthTxns, kw]);

    /* ── export ── */
    const exportRecon = () => {
      const rows = [];
      recon.pairs.forEach(p => rows.push({
        สถานะ: 'ตั้งแล้ว', วันที่เติม: p.txn.trnDate, ทะเบียน: p.txn.plateNo, ผู้ถือบัตร: p.txn.driverName,
        แผนก: p.txn.deptCode, ปั๊ม: p.txn.pp20Name || p.txn.merchantName, ชนิด: p.txn.productName,
        ลิตร: fcNum(p.txn.qtyLitre), ยอดธนาคาร: fcNum(p.txn.amount), เลขใบกำกับ: p.txn.invoiceNo,
        ยอดMango: (p.mangos || [p.mango]).reduce((s2, m) => s2 + fcNum(m.amount), 0),
        เลขเอกสารOF: [...new Set((p.mangos || [p.mango]).map(m => m.docNo))].join(', '),
        รอบOF: fcFmtMonth(p.mango.ym),
        รายละเอียดMango: (p.mangos || [p.mango]).map(m => m.itemName).join(' + '),
        ยอดขาด: p.shortfall ? fcNum(p.shortfall) : '',
        เลขPV: p.mango.pvNo || '', วันที่จ่าย: p.mango.payDate || '',
        สถานะจ่าย: fcIsPaid(p.mango) ? 'ตัดจ่ายแล้ว' : 'ยังไม่ตัดจ่าย',
        วิธีจับคู่: p.how === 'manual' ? 'ยืนยันเอง' : p.how === 'invoice' ? 'เลขใบกำกับ'
          : p.how === 'combo' ? 'รวมหลายใบ' : p.how === 'partial' ? 'ตั้งไม่ครบ' : 'อัตโนมัติ',
      }));
      pendingRows.forEach(t => rows.push({
        สถานะ: 'ยังไม่ได้ตั้ง', วันที่เติม: t.trnDate, ทะเบียน: t.plateNo, ผู้ถือบัตร: t.driverName,
        แผนก: t.deptCode, ปั๊ม: t.pp20Name || t.merchantName, ชนิด: t.productName,
        ลิตร: fcNum(t.qtyLitre), ยอดธนาคาร: fcNum(t.amount), เลขใบกำกับ: t.invoiceNo,
        ยอดMango: '', เลขเอกสารOF: '', รอบOF: '', รายละเอียดMango: '', ยอดขาด: '',
        เลขPV: '', วันที่จ่าย: '', สถานะจ่าย: '', วิธีจับคู่: '',
      }));
      recon.mangoOnly.forEach(m => rows.push({
        สถานะ: 'ตั้งเกิน (ไม่มีในไฟล์ธนาคาร)', วันที่เติม: m.eventDate, ทะเบียน: m.plateNo, ผู้ถือบัตร: m.empName,
        แผนก: m.deptCode, ปั๊ม: m.vendor, ชนิด: '', ลิตร: '', ยอดธนาคาร: '', เลขใบกำกับ: m.invoiceNo,
        ยอดMango: fcNum(m.amount), เลขเอกสารOF: m.docNo, รอบOF: fcFmtMonth(m.ym), รายละเอียดMango: m.itemName, ยอดขาด: '',
        เลขPV: m.pvNo || '', วันที่จ่าย: m.payDate || '',
        สถานะจ่าย: fcIsPaid(m) ? 'ตัดจ่ายแล้ว' : 'ยังไม่ตัดจ่าย', วิธีจับคู่: '',
      }));
      const cols = Object.keys(rows[0] || { สถานะ: '' }).map(k => ({
        key: k, label: k, type: /ยอด|ลิตร/.test(k) ? 'number' : 'text',
      }));
      exportRowsToExcel(rows, cols, {
        filename: 'fleetcard_' + program + '_recon_' + ym, sheetName: 'กระทบยอด',
        title: 'กระทบยอด ' + fcProgInfo(program).label + ' · รอบ ' + fcFmtMonth(ym),
      });
    };
    // Fleet Card: ไฟล์ DATA โครงเดียวกับไฟล์งานของฝ่ายการเงิน (สรุป / ฟอร์ม OF / FORM BANK / OF-Mango / Dep.)
    //   บัตรผู้บริหารไม่มีทะเบียน/ฟอร์ม OF แบบนี้ → ยังใช้ exportRecon ข้างบน
    //   ยังมีรายการไม่มีแผนก → ถามก่อนออกไฟล์ (ไม่งั้นยอดรายฝ่ายไปกองที่ "ไม่ระบุ" แล้วอ่านไม่ตรงกับรายทะเบียน)
    const downloadData = () => fcExportDataWorkbook({ ym, txns: monthTxns, recon, cross });
    const exportData = () => {
      if (!readOnly && deptMissing.length) setDeptAsk({ thenExport: true });
      else downloadData();
    };
    /* บันทึกแผนกที่ผู้ใช้ระบุ — เก็บรายรายการ (fcDeptTxn.<id>)
     *   ผู้ถือบัตรที่มีชื่อและยังไม่เคยมีแผนกจากที่ไหนเลย → เติมลงตารางผู้ถือบัตรด้วย เดือนหน้าจะได้ไม่ต้องถามซ้ำ
     *   (เฉพาะเมื่อทุกรายการของคนนั้นได้แผนกเดียวกัน) · บัตร VIP ไม่มีชื่อผู้ถือ = ไม่จำ ถามใหม่ทุกรอบ */
    const saveDepts = (changes, thenExport) => {
      const touched = {}, fresh = {};
      changes.forEach(c => {
        const name = fcTrim(c.t.driverName);
        if (name && c.dept && !fcDeptAuto(c.t)) (fresh[name] = fresh[name] || {})[c.dept] = true;
        fcOvSet(FC_DEPT_TXN + c.t.id, c.dept);
        touched[c.t.id] = true;
      });
      const remember = Object.keys(fresh).filter(n => Object.keys(fresh[n]).length === 1)
        .map(n => ({ key: n, dept: Object.keys(fresh[n])[0] }));
      if (remember.length) fcSaveDeptMap(remember);
      // แผนกอยู่นอก state ของ React → เปลี่ยนตัว object ของแถวที่แก้ ให้ตาราง/สรุปที่ memo ไว้คำนวณใหม่
      if (changes.length) setTxns(prev => prev.map(t => touched[t.id] ? { ...t } : t));
      setDeptAsk(null);
      if (changes.length) say('บันทึกแผนก ' + changes.length + ' รายการแล้ว' +
        (remember.length ? ' · จำแผนกของ ' + remember.map(r => r.key).join(', ') + ' ไว้ใช้รอบถัดไป' : ''));
      if (thenExport) downloadData();
    };
    // ฟอร์มตั้ง OF เฉพาะรายการที่ยังไม่ได้ตั้ง — คอลัมน์ตามที่ใช้คีย์เข้า Mango
    const exportOfForm = () => {
      if (!pendingRows.length) { say('ไม่มีรายการค้างตั้ง'); return; }
      if (isExecProg) {
        // บัตรเครดิต: statement ไม่แยก VAT / ไม่มีเลขใบกำกับ → ให้คอลัมน์เท่าที่มีจริง
        exportRowsToExcel(pendingRows.map(t => ({
          trnDate: t.trnDate, postDate: t.postDate,
          merchant: t.merchantName || '', cardHolder: t.driverName || '', cardNo: t.cardNo || '',
          amount: fcNum(t.amount), statementDate: t.statementDate || '', dueDate: t.dueDate || '',
          note: t.note || '',
        })), [
          { key: 'trnDate', label: 'วันที่ทำรายการ', type: 'date' },
          { key: 'postDate', label: 'วันที่ลงบัญชี', type: 'date' },
          { key: 'merchant', label: 'ร้านค้า' },
          { key: 'cardHolder', label: 'ผู้ถือบัตร' },
          { key: 'cardNo', label: 'เลขบัตร' },
          { key: 'amount', label: 'จำนวนเงิน', type: 'number' },
          { key: 'statementDate', label: 'วันสรุปยอด', type: 'date' },
          { key: 'dueDate', label: 'ครบกำหนดชำระ', type: 'date' },
          { key: 'note', label: 'หมายเหตุ' },
        ], {
          filename: 'execcard_pending_' + ym, sheetName: 'ค้างตั้ง OF',
          title: 'รายการค้างตั้ง OF · EXECUTIVE CARD · รอบ ' + fcFmtMonth(ym),
        });
        return;
      }
      const rows = pendingRows.map(t => ({
        expenseCode: 'E076',
        costCode: t.deptCode || '',
        company: t.pp20Name || t.merchantName || '',
        address: t.pp20Address || t.merchantLocation || '',
        branchNo: t.branchNo || '',
        description: 'ค่าน้ำมัน Fleet Card ฝ่าย ' + (t.deptCode || '-') + ' ' + (t.driverName || '') +
          ' ทะเบียนรถ ' + (t.plateNo || ''),
        invoiceNo: t.invoiceNo,
        invDate: t.trnDate,
        taxId: t.merchantTaxId || '',
        amount: fcNum(t.amount),
        vat: fcNum(t.vatAmount),
        exVat: fcNum(t.exVatAmount),
        litre: fcNum(t.qtyLitre),
      }));
      exportRowsToExcel(rows, [
        { key: 'expenseCode', label: 'Expense Code' },
        { key: 'costCode', label: 'Cost Code' },
        { key: 'company', label: 'Company' },
        { key: 'address', label: 'Address' },
        { key: 'branchNo', label: 'Branch No' },
        { key: 'description', label: 'Description' },
        { key: 'invoiceNo', label: 'INVOICE_NO' },
        { key: 'invDate', label: 'Inv. Date', type: 'date' },
        { key: 'taxId', label: 'Tax ID' },
        { key: 'amount', label: 'Amount', type: 'number' },
        { key: 'vat', label: 'Vat', type: 'number' },
        { key: 'exVat', label: 'ก่อน VAT', type: 'number' },
        { key: 'litre', label: 'ลิตร', type: 'number' },
      ], {
        filename: 'fleetcard_' + program + '_of_pending_' + ym, sheetName: 'ตั้ง OF',
        title: 'รายการค้างตั้ง OF · ' + fcProgInfo(program).label + ' · รอบ ' + fcFmtMonth(ym),
      });
    };

    /* ── render ── */
    if (loading) {
      return (
        <div className="page">
          <div className="page-head"><div><div className="page-title">FLEET CARD / EXECUTIVE CARD</div></div></div>
          <div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--ink-500)' }}>กำลังโหลดข้อมูล…</div>
        </div>
      );
    }

    const diff = stats.bankAmt - stats.mangoAmt;
    // OF ที่เหลือและ 'ไปตัดบิลรอบอื่นได้' ไม่ใช่ตั้งเกิน — แค่รอตัดรอบถัดไป
    const overReal = recon.mangoOnly.filter(m => !cross.nextOfIds[m.id] && fcPayState(m) === 'none'
      && m.status !== 'over_ok' && m.status !== 'over_cancelled');
    // ใบที่ตัดจ่าย PV ไปแล้ว = กระทบจบตั้งแต่รอบก่อน (ไฟล์ OF ที่ export มาคร่อมหลายรอบเลยติดมาซ้ำ)
    const paidClosed = recon.mangoOnly.filter(m => !cross.nextOfIds[m.id] && fcPayState(m) === 'full');
    const paidPartial = recon.mangoOnly.filter(m => !cross.nextOfIds[m.id] && fcPayState(m) === 'partial');
    const overRealCount = overReal.length;
    const overRealAmt = overReal.reduce((s2, m) => s2 + fcNum(m.amount), 0);

    return (
      <div className="page">
        <div className="page-head">
          <div>
            <div className="page-title">{fcProgInfo(program).icon} {fcProgInfo(program).label}</div>
            <div className="page-sub">
              เทียบไฟล์เรียกเก็บของธนาคารกับรายการที่ตั้ง OF ใน Mango — รายรายการ
              {months.length ? ' • มีข้อมูล ' + months.length + ' รอบ' : ''}
              {saving ? ' • กำลังบันทึก…' : ''}
            </div>
          </div>
          <div className="page-head-r" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
            {!readOnly && (
              <>
                <button className="btn" onClick={() => setImportKind('bank')}>
                  <Icon name="download" size={14} /> นำเข้าไฟล์ธนาคาร
                </button>
                <button className="btn" onClick={() => setImportKind('mango')}>
                  <Icon name="download" size={14} /> นำเข้า OF จาก Mango
                </button>
              </>
            )}
            {!readOnly && (window.WTPAuth && window.WTPAuth.can('canDelete')) && (
              <button className="btn btn-ghost" onClick={() => setPurgeOpen(true)} title="ล้างข้อมูลที่นำเข้ามาผิด แล้วนำเข้าใหม่">
                <Icon name="trash" size={14} /> ล้างข้อมูลที่นำเข้า
              </button>
            )}
            <button className="btn btn-ghost" onClick={reload} title="โหลดข้อมูลใหม่">
              <Icon name="refresh" size={14} /> รีเฟรช
            </button>
          </div>
        </div>

        {/* ── แท็บชุดบัตร — ข้อมูลแยกขาดจากกัน ── */}
        <div className="card anim-in" style={{ padding: 4, marginBottom: 14, display: 'flex', gap: 5 }}>
          {FC_PROGRAMS.map(p => (
            <button key={p.key} onClick={() => setProgram(p.key)}
              style={{ flex: '1 1 0', border: 'none', cursor: 'pointer', borderRadius: 8, padding: '8px 12px', textAlign: 'left', fontFamily: 'inherit',
                background: program === p.key ? 'linear-gradient(135deg, var(--brand-500), var(--brand-700))' : 'var(--ink-50)',
                color: program === p.key ? '#fff' : 'var(--ink-700)',
                boxShadow: program === p.key ? '0 3px 10px color-mix(in oklch, var(--brand-500) 28%, transparent)' : 'none',
                transition: 'all .14s' }}>
              <div style={{ fontSize: 13.5, fontWeight: 700, letterSpacing: '.02em' }}>{p.icon} {p.label}</div>
              <div style={{ fontSize: 10.5, opacity: program === p.key ? 0.9 : 0.6, marginTop: 1 }}>
                {p.sub}{progCount[p.key] ? ' · ' + progCount[p.key] + ' รายการในระบบ' : ' · ยังไม่มีข้อมูล'}
              </div>
            </button>
          ))}
        </div>

        {/* เลือกรอบเดือน */}
        <div className="card" style={{ padding: '10px 14px', marginBottom: 14, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--ink-700)' }}>รอบเดือน</span>
          {months.length === 0 && <span style={{ fontSize: 12, color: 'var(--ink-500)' }}>ยังไม่มีข้อมูลของ {fcProgInfo(program).label} — เริ่มด้วยปุ่ม "นำเข้าไฟล์ธนาคาร"</span>}
          {months.map(m => (
            <button key={m} onClick={() => setYm(m)}
              style={{ border: 'none', cursor: 'pointer', borderRadius: 6, padding: '3px 10px', fontSize: 11.5, fontWeight: 600,
                background: m === ym ? 'var(--brand-500)' : 'var(--ink-100)', color: m === ym ? '#fff' : 'var(--ink-700)' }}>
              {fcFmtMonth(m)}
            </button>
          ))}
          {months.length > 0 && (
            <label style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--ink-600)', display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
              title="รายการที่เติมปลายเดือนมักไปตั้ง OF รอบถัดไป — เปิดไว้เพื่อจับคู่ข้ามรอบได้">
              <input type="checkbox" checked={crossMonth} onChange={e => setCrossMonth(e.target.checked)} />
              จับคู่กับ OF รอบข้างเคียง (±1 เดือน)
            </label>
          )}
        </div>

        {months.length > 0 && (
          <>
            {/* ── กระดานสรุปแบบกระดาษทด: ยอดบิล → ตั้งได้แล้ว → ยังขาด (+ ข้ามรอบ) ── */}
            <div className="card" style={{ padding: '12px 16px', marginBottom: 14 }}>
              <div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', alignItems: 'stretch' }}>
                {[
                  { label: isExecProg ? 'ยอดรูดบัตรจริง' : 'ยอดเรียกเก็บจริง', value: stats.bankAmt,
                    sub: stats.bankCount + ' รายการ' + (isExecProg ? '' : ' · ' + fmtNum(stats.bankLitre, 2) + ' ลิตร'),
                    color: 'var(--ink-800)' },
                  { label: '✅ ตั้ง OF ได้แล้ว', value: stats.matchedAmt,
                    sub: stats.matchedCount + '/' + stats.bankCount + ' รายการ', color: 'var(--good)' },
                  { label: '⚠️ ยอดที่ยังขาด', value: stats.pendingAmt + stats.shortfallAmt,
                    sub: stats.pendingCount + ' รายการยังไม่ตั้ง'
                      + (pendingRows.filter(t => t.status === 'of_pending').length
                        ? ' (ตั้งแล้วรอขึ้นระบบ ' + pendingRows.filter(t => t.status === 'of_pending').length + ')' : '')
                      + (stats.shortfallCount ? ' · ตั้งไม่ครบ ' + stats.shortfallCount : ''),
                    color: (stats.pendingCount + stats.shortfallCount) ? 'var(--warn)' : 'var(--good)' },
                  { label: '❌ ตั้งเกิน (ไม่รวมรอตัดรอบหน้า)', value: overRealAmt,
                    sub: overRealCount + ' ใบ'
                      + (overReal.filter(m => m.status === 'over_confirmed').length ? ' · รอ Mango ตัดออก ' + overReal.filter(m => m.status === 'over_confirmed').length : '')
                      + (cross.outRows.length ? ' · รอตัดรอบหน้า ' + cross.outRows.length : '')
                      + (paidClosed.length ? ' · จ่ายแล้ว/ปิดงาน ' + paidClosed.length : '')
                      + (paidPartial.length ? ' · จ่ายบางส่วน ' + paidPartial.length + ' (ต้องเช็คเอง)' : ''),
                    color: overRealCount ? 'var(--bad)' : 'var(--good)' },
                ].concat(stats.hasPayInfo ? [{
                  label: '💸 ตั้งแล้ว · ยังไม่ตัดจ่าย', value: stats.unpaidAmt,
                  sub: stats.unpaidCount + ' รายการ · ตัดจ่ายแล้ว ' + stats.paidCount,
                  color: stats.unpaidCount ? 'var(--warn)' : 'var(--good)',
                }] : []).map((k, i) => (
                  <div key={i} style={{ flex: '1 1 180px', minWidth: 150, paddingLeft: i ? 16 : 0,
                    borderLeft: i ? '1px solid var(--line)' : 'none' }}>
                    <div style={{ fontSize: 11.5, color: 'var(--ink-500)', fontWeight: 600 }}>{k.label}</div>
                    <div style={{ fontSize: 19, fontWeight: 800, color: k.color, fontVariantNumeric: 'tabular-nums', lineHeight: 1.3 }}>
                      {fmtMoney(k.value)}
                    </div>
                    <div style={{ fontSize: 10.5, color: 'var(--ink-400)' }}>{k.sub}</div>
                  </div>
                ))}
              </div>

              {/* ข้ามรอบ — อธิบายว่าทำไมยอดบิลกับยอดตั้งของรอบนี้ไม่เท่ากัน */}
              {(cross.inRows.length > 0 || cross.outRows.length > 0) && (
                <div style={{ marginTop: 12, paddingTop: 10, borderTop: '1px dashed var(--line)', fontSize: 12, color: 'var(--ink-700)',
                  display: 'flex', gap: 16, flexWrap: 'wrap' }}>
                  <span style={{ fontWeight: 700, color: 'var(--ink-600)' }}>🔁 ข้ามรอบ</span>
                  <span title="รายการในบิลรอบนี้ ที่ไปตั้ง OF ในรอบอื่น">
                    บิลรอบนี้ใช้ OF รอบอื่น <b>{cross.inRows.length}</b> รายการ
                    ({fmtMoney(cross.inRows.reduce((s, p) => s + fcNum(p.txn.amount), 0))})
                  </span>
                  <span title="OF ที่ตั้งในรอบนี้ แต่ไปตัดกับบิลของอีกรอบ">
                    OF รอบนี้ไปตัดบิลรอบอื่น <b>{cross.outRows.length}</b> รายการ
                    ({fmtMoney(cross.outRows.reduce((s, r) => s + fcNum(r.mango.amount), 0))})
                  </span>
                  <span style={{ marginLeft: 'auto', color: 'var(--ink-500)' }}>
                    ผลต่างสุทธิ {fmtMoney(cross.outRows.reduce((s, r) => s + fcNum(r.mango.amount), 0)
                      - cross.inRows.reduce((s, p) => s + fcNum(p.txn.amount), 0))} บาท
                  </span>
                </div>
              )}

              {/* บัตรเครดิต: ยอดยกมา ↔ ตัดชำระ ต้องหักล้างกันพอดี */}
              {isExecProg && monthOther.length > 0 && (() => {
                const bal = monthOther.filter(r => r.txnType === 'balance').reduce((s, r) => s + fcNum(r.amount), 0);
                const pay = monthOther.filter(r => r.txnType === 'payment').reduce((s, r) => s + fcNum(r.amount), 0);
                const credit = monthOther.filter(r => r.txnType === 'credit').reduce((s, r) => s + fcNum(r.amount), 0);
                const ok = Math.abs(bal + pay) < 0.01;
                return (
                  <div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px dashed var(--line)', fontSize: 12,
                    color: ok ? 'var(--ink-600)' : 'var(--bad)', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
                    <span style={{ fontWeight: 700 }}>{ok ? '✓' : '⚠'} ยอดยกมา / ตัดชำระ</span>
                    <span>ยกมา {fmtMoney(bal)}</span>
                    <span>ตัดชำระ {fmtMoney(pay)}</span>
                    {credit ? <span>เงินคืน/โอนเข้า {fmtMoney(credit)}</span> : null}
                    <span style={{ marginLeft: 'auto' }}>
                      {ok ? 'หักล้างกันพอดี' : 'ต่างกัน ' + fmtMoney(bal + pay) + ' — ตรวจกับใบแจ้งยอด'}
                    </span>
                  </div>
                );
              })()}
            </div>

            {/* แถบสรุปสถานะ */}
            <div className="card" style={{ padding: '12px 16px', marginBottom: 14, borderLeft: '4px solid ' +
              (stats.pendingCount === 0 && overRealCount === 0 ? 'var(--good)' : 'var(--warn)'),
              display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
              <div style={{ flex: 1, minWidth: 260 }}>
                <div style={{ fontWeight: 700, fontSize: 13.5, color: 'var(--ink-800)' }}>
                  {monthMangos.length === 0
                    ? '📒 ยังไม่ได้นำเข้ารายงาน OF ของรอบนี้ — นำเข้าก่อนถึงจะบอกได้ว่าตั้งครบหรือยัง'
                    : (stats.pendingCount === 0 && overRealCount === 0
                      ? '✅ ตั้งครบแล้วทั้งรอบ — ' + stats.matchedCount + '/' + stats.bankCount + ' รายการจับคู่ได้' +
                        (cross.outRows.length ? ' · มี ' + cross.outRows.length + ' ใบรอตัดบิลรอบถัดไป' : '')
                      : '⚠ ยังไม่ครบ — ค้างตั้ง ' + stats.pendingCount + ' รายการ (' + fmtMoney(stats.pendingAmt) + ' บาท)' +
                        (overRealCount ? ' · ตั้งเกิน ' + overRealCount + ' รายการ (' + fmtMoney(overRealAmt) + ')' : ''))}
                </div>
                <div style={{ fontSize: 11.5, color: 'var(--ink-500)', marginTop: 2 }}>
                  {isExecProg
                    ? 'statement บัตรเครดิตไม่มีเลขใบกำกับ → จับคู่ด้วยยอดเงิน + วันที่ + ชื่อร้านค้า · คู่ที่ไม่มั่นใจจับคู่เองได้'
                    : 'รายงาน OF 9.3 ไม่มีวันที่เติม → จับคู่ด้วยยอดเงิน + ชื่อปั๊ม + ทะเบียน/ชื่อผู้ถือบัตร · คู่ที่ไม่มั่นใจจับคู่เองได้'}
                  {stats.weakCount ? ' · มี ' + stats.weakCount + ' คู่ที่สัญญาณน้อย (ป้าย "ควรตรวจซ้ำ")' : ''}
                  {stats.hasPayInfo
                    ? ' · ตัดจ่ายแล้ว ' + stats.paidCount + '/' + stats.matchedCount + ' รายการ'
                    : ' · ยังไม่มีข้อมูล PV — นำเข้ารายงาน OF ยอดรวมเพิ่มเพื่อดูว่าตัดจ่ายแล้วหรือยัง'}
                </div>
              </div>
              {isExecProg
                ? <button className="btn btn-ghost" onClick={exportRecon}><Icon name="download" size={14} /> Excel กระทบยอด</button>
                : <button className="btn btn-ghost" onClick={exportData}
                    title="ไฟล์ DATA ของรอบนี้ — ชีตสรุป BANK/OF รายฝ่าย-รายทะเบียน · ฟอร์มตั้ง OF · FORM BANK · OF-Mango · Dep.">
                    <Icon name="download" size={14} /> ไฟล์ DATA (Excel)
                  </button>}
              {!isExecProg && !readOnly && (
                <button className="btn btn-ghost" onClick={() => setDeptAsk({})}
                  title="ระบุว่าแต่ละรายการแผนกไหนเบิกบัตรไปใช้ — บัตร VIP / รายการที่ยังไม่มีแผนก"
                  style={deptMissing.length ? { borderColor: 'var(--warn)', color: 'var(--warn)', fontWeight: 700 } : undefined}>
                  🏷️ ระบุแผนก{deptMissing.length ? ' (ขาด ' + deptMissing.length + ')' : ''}
                </button>
              )}
              {pendingRows.length > 0 && (
                <button className="btn" onClick={exportOfForm}><Icon name="download" size={14} /> ฟอร์มตั้ง OF ({pendingRows.length})</button>
              )}
              {!readOnly && recon.pairs.some(p => p.how !== 'manual') && (
                <button className="btn btn-primary" onClick={confirmAuto}><Icon name="check" size={14} /> ยืนยันคู่อัตโนมัติ</button>
              )}
            </div>

            {/* แท็บ */}
            <div className="card" style={{ padding: 4, marginBottom: 14, display: 'flex', gap: 5 }}>
              {[
                { key: 'recon', label: '🔎 กระทบยอด', sub: 'ตั้งแล้ว / ยังไม่ตั้ง / ตั้งเกิน' },
                { key: 'txns', label: isExecProg ? '💳 รายการใช้จ่าย' : '⛽ รายการเติมน้ำมัน', sub: 'รายการทั้งหมดจากไฟล์ธนาคาร' },
                { key: 'summary', label: '📊 สรุป', sub: isExecProg ? 'รายร้านค้า · ผู้ถือบัตร' : 'รายบัตร · แผนก · ชนิดน้ำมัน · ปั๊ม' },
              ].map(t => (
                <button key={t.key} onClick={() => setTab(t.key)}
                  style={{ flex: '1 1 0', border: 'none', cursor: 'pointer', borderRadius: 8, padding: '6px 10px', textAlign: 'left', fontFamily: 'inherit',
                    background: tab === t.key ? 'linear-gradient(135deg, var(--brand-500), var(--brand-700))' : 'var(--ink-50)',
                    color: tab === t.key ? '#fff' : 'var(--ink-700)', transition: 'all .14s' }}>
                  <div style={{ fontSize: 12.5, fontWeight: 700 }}>{t.label}</div>
                  <div style={{ fontSize: 10, opacity: tab === t.key ? 0.9 : 0.6, marginTop: 1 }}>{t.sub}</div>
                </button>
              ))}
            </div>

            {tab === 'recon' && (
              <FcReconTab recon={recon} pendingRows={pendingRows} ignoredRows={ignoredRows}
                readOnly={readOnly} filter={reconFilter} setFilter={setReconFilter} ym={ym} program={program} hasPayInfo={stats.hasPayInfo}
                nextOfIds={cross.nextOfIds} paidClosed={paidClosed}
                onLink={(t) => setLinkTxn(t)} onUnlink={unlinkPair} onToggleIgnore={toggleIgnore}
                onConfirmOne={confirmOne} onLinkPair={linkPair}
                onTxnStatus={setTxnStatus} onTxnNote={setTxnNote} onDeletePaid={deletePaidClosed}
                onMangoStatus={setMangoStatus} onMangoNote={setMangoNote} onDeleteMango={deleteMangoRow}
                onMoveMango={moveMangoYm}
                canDelete={!!(window.WTPAuth && window.WTPAuth.can('canDelete'))} />
            )}
            {tab === 'txns' && <FcTxnsTab rows={filteredTxns} q={q} setQ={setQ} ym={ym} program={program} recon={recon} otherRows={monthOther} />}
            {tab === 'summary' && <FcSummaryTab rows={monthTxns} allTxns={progTxns} ym={ym} program={program} yearRows={yearRows} />}
          </>
        )}

        {importKind && (
          <FcImportModal kind={importKind} defaultYm={ym} program={program} otherCustomerIds={otherCustomerIds}
            onClose={() => setImportKind(null)}
            onCommit={importKind === 'bank' ? commitBank : commitMango} />
        )}
        {linkTxn && (
          <FcLinkModal txn={linkTxn} mangoOnly={recon.mangoOnly} usedPairs={recon.pairs}
            onClose={() => setLinkTxn(null)} onLink={linkPair} />
        )}
        {purgeOpen && (
          <FcPurgeModal program={program} ym={ym} progTxns={progTxns} progMangos={progMangos}
            onClose={() => setPurgeOpen(false)} onPurge={purgeRows} />
        )}
        {deptAsk && (
          <FcDeptModal rows={monthTxns} pairOf={pairOfTxn} ym={ym} options={fcDeptOptions(progTxns, progMangos)}
            thenExport={!!deptAsk.thenExport} onClose={() => setDeptAsk(null)}
            onSkip={() => { setDeptAsk(null); downloadData(); }} onSave={saveDepts} />
        )}
      </div>
    );
  }

  /* ── ตารางกระทบยอดแบบคู่ซ้าย-ขวา (สไตล์เดียวกับหน้ากระทบยอดธนาคาร) ────
   *   1 แถว = 1 คู่: ฝั่งซ้าย = บิลจากธนาคาร (ชุดบัตรที่เลือก) · ฝั่งขวา = OF ใน Mango
   *   ฝั่งที่ไม่มีคู่จะขึ้นแถบลายทาง "— ไม่พบ —" ให้เห็นทันทีว่าขาดฝั่งไหน */
  const FC_TH_BG = 'color-mix(in srgb, var(--brand-500) 12%, white)';
  // ตารางกระชับ: หัวไม่ตัดบรรทัด · แถวเตี้ย · ตัวเลขเรียงหลักตรงกัน
  const fcTh = (align, w) => ({
    padding: '6px 8px', fontSize: 10.5, fontWeight: 700, color: 'var(--ink-600)', letterSpacing: .2,
    background: FC_TH_BG, borderBottom: '1px solid var(--line)', whiteSpace: 'nowrap',
    textAlign: align || 'left', position: 'sticky', top: 0, zIndex: 1, width: w,
  });
  const fcTd = (align, extra) => Object.assign({
    padding: '4px 8px', fontSize: 11.5, borderTop: '1px solid var(--ink-100)',
    textAlign: align || 'left', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
  }, align === 'right' ? { fontVariantNumeric: 'tabular-nums' } : null, extra || {});

  function FcSide({ rows, emptyLabel, tone }) {
    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) => (
          <div key={i} style={{ display: 'flex', alignItems: 'baseline', gap: 7 }}>
            <span style={{ color: 'var(--ink-500)', whiteSpace: 'nowrap', fontSize: 11 }}>{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' }}
              title={r.refTitle || r.ref}>{r.ref}</span> : null}
            <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}
              title={r.descTitle || r.desc}>{r.desc || '—'}</span>
            <span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, whiteSpace: 'nowrap', fontSize: 12,
              color: tone === 'mango' ? 'var(--brand-700)' : 'var(--ink-800)' }}>{fmtNum(Math.abs(fcNum(r.amount)), 2)}</span>
          </div>
        ))}
        {rows.length > 1 && (
          <div style={{ borderTop: '1px dashed var(--line)', paddingTop: 2, textAlign: 'right', fontSize: 11, color: 'var(--ink-500)' }}>
            รวม <b style={{ fontVariantNumeric: 'tabular-nums' }}>{fmtNum(rows.reduce((s, r) => s + fcNum(r.amount), 0), 2)}</b>
          </div>
        )}
      </div>
    );
  }

  const FC_STATUS = {
    exact:     { t: 'ตรงเป๊ะ',      c: 'var(--good)' },
    confirmed: { t: 'ยืนยันแล้ว',   c: 'var(--good)' },
    manual:    { t: 'จับคู่เอง',    c: 'var(--good)' },
    suggested: { t: 'รอยืนยัน',     c: 'var(--warn)' },
    combo:     { t: 'รวมหลายใบ',   c: 'var(--warn)' },
    partial:   { t: 'ตั้งไม่ครบ',   c: 'var(--bad)' },
    ambiguous: { t: '⚠ ตรวจก่อน',   c: 'var(--bad)' },
    bankOnly:  { t: 'ยังไม่ตั้ง OF', c: 'var(--bad)' },
    mangoOnly: { t: 'ตั้งเกิน',      c: 'var(--bad)' },
    nextCycle: { t: 'รอตัดรอบถัดไป', c: 'var(--warn)' },
    paidClosed:{ t: 'จ่ายแล้ว · ปิดงาน', c: 'var(--ink-500)' },
    paidPartial:{ t: 'จ่ายบางส่วน',      c: 'var(--warn)' },
    ignored:   { t: 'ไม่ต้องตั้ง',   c: 'var(--ink-500)' },
  };
  // ย่อเหตุผลจับคู่ให้เหลือบรรทัดเดียว (ตัด "ยอดตรง" ที่มีทุกคู่ + เอาแค่ 2 สัญญาณแรก) — ตัวเต็มดูได้ที่ tooltip
  function fcWhyShort(why) {
    const parts = String(why || '').split(' · ').filter(p => p && p !== 'ยอดตรง');
    if (!parts.length) return 'ยอดตรง';
    return parts.slice(0, 2).join(' · ') + (parts.length > 2 ? ' +' + (parts.length - 2) : '');
  }
  function FcStatusBadge({ kind, sub }) {
    const m = FC_STATUS[kind] || { t: kind, c: 'var(--ink-500)' };
    return (
      <div style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'flex-start', gap: 1, maxWidth: 112 }}>
        <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 title={sub} style={{ fontSize: 9.5, color: 'var(--ink-400)', maxWidth: '100%',
            whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{fcWhyShort(sub)}</span>
        ) : null}
      </div>
    );
  }

  /* ── ช่อง "ติดตาม / หมายเหตุ" ในตารางงานค้าง ──
   *   ป้ายบอกว่าตอนนี้อยู่ขั้นไหน + ปุ่มไปขั้นถัดไป + ช่องหมายเหตุพิมพ์เอง
   *   (คลิกที่ช่องหมายเหตุเพื่อพิมพ์ · กด Enter หรือคลิกที่อื่นเพื่อบันทึก) */
  const FC_TRACK_LABEL = {
    of_pending:     { t: '📝 ตั้ง OF แล้ว รอขึ้นระบบ', c: 'var(--brand-600)' },
    over_confirmed: { t: '⚠ เกินจริง — รอตัดออก',      c: 'var(--bad)' },
    over_cancelled: { t: '✂ ตัดออกแล้ว — ลบได้',        c: 'var(--good)' },
    over_ok:        { t: '✓ ตรวจแล้ว ไม่ใช่ตั้งเกิน',    c: 'var(--good)' },
    ignored:        { t: '🚫 ไม่ต้องตั้ง',               c: 'var(--ink-500)' },
  };
  function FcTrackCell({ row, kind, steps, readOnly, onStatus, onNote, moveTo, onMove }) {
    const [editing, setEditing] = useState(false);
    const [draft, setDraft] = useState('');
    const cur = FC_TRACK_LABEL[fcTrim(row.status)];
    const note = fcTrim(row.note);
    const save = () => {
      setEditing(false);
      if (draft !== note) onNote(row, draft.trim());
    };
    return (
      <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
        {cur && (
          <span style={{ fontSize: 10.5, fontWeight: 700, color: cur.c, background: 'color-mix(in oklch, ' + cur.c + ' 13%, #fff)',
            borderRadius: 6, padding: '2px 7px', width: 'fit-content' }}>{cur.t}</span>
        )}
        {!readOnly && (steps || []).length > 0 && (
          <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
            {steps.map((s, i) => (
              <button key={i} title={s.title} onClick={() => onStatus(row, s.to)}
                style={{ fontSize: 10.5, padding: '2px 7px', borderRadius: 6, cursor: 'pointer', fontFamily: 'inherit',
                  border: '1px solid ' + s.color, background: '#fff', color: s.color, fontWeight: 700, whiteSpace: 'nowrap' }}>
                {s.label}
              </button>
            ))}
          </div>
        )}
        {!readOnly && moveTo && onMove && (
          <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
            {moveTo.map((mv, i) => (
              <button key={i} title={mv.title} onClick={() => onMove(row, mv.delta)}
                style={{ fontSize: 10, padding: '2px 6px', borderRadius: 6, cursor: 'pointer', fontFamily: 'inherit',
                  border: '1px dashed var(--brand-400)', background: '#fff', color: 'var(--brand-700)', whiteSpace: 'nowrap' }}>
                📅 {mv.label}
              </button>
            ))}
          </div>
        )}
        {editing ? (
          <input autoFocus className="input" value={draft} placeholder="พิมพ์หมายเหตุ…"
            onChange={e => setDraft(e.target.value)} onBlur={save}
            onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false); }}
            style={{ fontSize: 11, padding: '3px 6px', width: '100%' }} />
        ) : (
          <div onClick={() => { if (readOnly) return; setDraft(note); setEditing(true); }}
            title={readOnly ? note : 'คลิกเพื่อพิมพ์หมายเหตุ'}
            style={{ fontSize: 10.5, color: note ? 'var(--ink-700)' : 'var(--ink-300)', cursor: readOnly ? 'default' : 'text',
              whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 200,
              borderBottom: '1px dashed ' + (note ? 'var(--line)' : 'transparent'), paddingBottom: 1 }}>
            {note || (readOnly ? '—' : '📝 เพิ่มหมายเหตุ')}
          </div>
        )}
      </div>
    );
  }

  function FcReconTable({ items, leftLabel, selectable, selBank, selMango, onToggle, showPv, showTrack, emptyText }) {
    if (!items.length) return <div style={{ padding: 24, textAlign: 'center', color: 'var(--ink-400)', fontSize: 12.5 }}>{emptyText || 'ไม่มีรายการ'}</div>;
    const th = { padding: '8px 10px', fontWeight: 700, background: FC_TH_BG, borderBottom: '1px solid var(--line)', textAlign: 'left' };
    const thL = Object.assign({}, th, { borderLeft: '1px solid var(--ink-100)' });
    return (
      <div style={{ maxHeight: '58vh', 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={Object.assign({}, th, { width: 30 })}></th>}
              <th style={th}>{leftLabel}</th>
              <th style={thL}>MANGO (ใบ OF)</th>
              <th style={Object.assign({}, thL, { width: 116 })}>สถานะ</th>
              {showPv && <th style={Object.assign({}, thL, { width: 120 })}>ตัดจ่าย (PV)</th>}
              {showTrack && <th style={Object.assign({}, thL, { width: 210 })}>ติดตาม / หมายเหตุ</th>}
              <th style={Object.assign({}, thL, { width: 104, textAlign: 'center' })}>จัดการ</th>
            </tr>
          </thead>
          <tbody>
            {items.map(it => {
              const checked = it.checkSide === 'bank' ? !!(selBank && selBank[it.checkId])
                : it.checkSide === 'mango' ? !!(selMango && selMango[it.checkId]) : false;
              return (
                <tr key={it.key} style={Object.assign({ borderTop: '1px solid var(--ink-100)' },
                  checked ? { background: 'color-mix(in oklch, var(--brand-500) 8%, transparent)' } : null)}>
                  {selectable && (
                    <td style={{ textAlign: 'center', verticalAlign: 'middle' }}>
                      {it.checkId != null
                        ? <input type="checkbox" checked={checked} onChange={() => onToggle(it.checkId, it.checkSide)} />
                        : null}
                    </td>
                  )}
                  <td style={{ verticalAlign: 'middle', padding: '7px 10px' }}>
                    <FcSide rows={it.bank} emptyLabel="— ไม่พบในบิลธนาคาร —" />
                  </td>
                  <td style={{ verticalAlign: 'middle', padding: '7px 10px', borderLeft: '1px solid var(--ink-100)' }}>
                    <FcSide rows={it.mango} emptyLabel="— ยังไม่ได้ตั้งใน Mango —" tone="mango" />
                  </td>
                  <td style={{ verticalAlign: 'middle', padding: '7px 8px', borderLeft: '1px solid var(--ink-100)' }}>
                    <FcStatusBadge kind={it.statusKind} sub={it.statusSub} />
                  </td>
                  {showPv && (
                    <td style={{ verticalAlign: 'middle', padding: '7px 8px', borderLeft: '1px solid var(--ink-100)', fontSize: 11 }}>
                      {it.pv || <span style={{ color: 'var(--ink-300)' }}>—</span>}
                    </td>
                  )}
                  {showTrack && (
                    <td style={{ verticalAlign: 'middle', padding: '6px 8px', borderLeft: '1px solid var(--ink-100)' }}>
                      {it.track}
                    </td>
                  )}
                  <td style={{ textAlign: 'center', verticalAlign: 'middle', borderLeft: '1px solid var(--ink-100)', padding: '6px 6px' }}>
                    {it.action}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    );
  }

  /* ── แท็บ 1: กระทบยอด (คู่ซ้าย-ขวา) ─────────────────────────────── */
  function FcReconTab({ recon, pendingRows, ignoredRows, readOnly, filter, setFilter, ym, program, hasPayInfo,
                        nextOfIds, paidClosed = [], onLink, onUnlink, onToggleIgnore, onConfirmOne, onLinkPair,
                        onTxnStatus, onTxnNote, onMangoStatus, onMangoNote, onDeleteMango, onDeletePaid, onMoveMango, canDelete }) {
    const isExecProg = program === 'exec';
    const prog = fcProgInfo(program);
    const [q, setQ] = useState('');
    const [sort, setSort] = useState('date_asc');     // date_asc | date_desc | amt_desc | amt_asc
    const [selBank, setSelBank] = useState({});
    const [selMango, setSelMango] = useState({});

    const toggleSel = (id, side) => {
      if (side === 'bank') setSelBank(s => Object.assign({}, s, { [id]: !s[id] }));
      else setSelMango(s => Object.assign({}, s, { [id]: !s[id] }));
    };
    const clearSel = () => { setSelBank({}); setSelMango({}); };
    const selBankIds = Object.keys(selBank).filter(k => selBank[k]);
    const selMangoIds = Object.keys(selMango).filter(k => selMango[k]);
    // ฝั่งบิลติ๊กได้ทั้งรายการที่ยังไม่ตั้ง OF และรายการที่จับคู่ไว้แล้ว (เอาไว้เพิ่มใบเข้าไปในคู่เดิม)
    const pairTxns = recon.pairs.map(p => p.txn);
    const selBankRows = pendingRows.concat(pairTxns).filter(t => selBank[t.id]);
    const selMangoRows = recon.mangoOnly.filter(m => selMango[m.id]);
    // ถ้าบิลที่เลือกมีคู่อยู่แล้ว ใบเดิมต้องติดไปด้วย — ไม่งั้นกด "จับคู่" แล้วใบเดิมหลุดออกเงียบๆ
    const selBankPair = selBankRows.length === 1
      ? recon.pairs.find(p => p.txn.id === selBankRows[0].id) : null;
    const keepRows = selBankPair ? (selBankPair.mangos || [selBankPair.mango]).filter(Boolean) : [];
    const linkRows = keepRows.concat(selMangoRows.filter(m => !keepRows.some(k => k.id === m.id)));
    const selBankAmt = selBankRows.reduce((s, r) => s + fcNum(r.amount), 0);
    const selMangoAmt = linkRows.reduce((s, r) => s + fcNum(r.amount), 0);
    const canLink = selBankIds.length === 1 && selMangoIds.length >= 1;

    /* ── แปลงข้อมูลเป็น "ฝั่ง" สำหรับตาราง ── */
    const bankSide = (t) => ({
      date: t.trnDate,
      ref: isExecProg ? '' : (t.plateNo || ''),
      desc: (isExecProg ? t.merchantName : (t.pp20Name || t.merchantName)) +
        (isExecProg ? '' : (t.productName ? ' · ' + t.productName : '')),
      descTitle: [t.driverName, t.pp20Name || t.merchantName, t.invoiceNo].filter(Boolean).join(' · '),
      amount: t.amount,
    });
    const mangoSide = (m) => ({
      date: m.eventDate || m.vchDate || m.refDate,
      ref: m.docNo,
      refTitle: [m.docNo, m.vchNo].filter(Boolean).join(' · '),
      desc: m.itemName || m.vendor || '—',
      descTitle: [m.itemName, m.vendor, m.docRemark].filter(Boolean).join(' · '),
      amount: m.amount,
    });
    const pvCell = (m) => {
      if (!m) return null;
      if (fcIsPaid(m)) return (
        <span style={{ color: 'var(--good)', fontWeight: 600 }}>
          {m.pvNo || 'จ่ายแล้ว'}{m.payDate ? <div style={{ fontSize: 10, color: 'var(--ink-500)', fontWeight: 400 }}>{m.payDate}</div> : null}
        </span>
      );
      return hasPayInfo ? <span style={{ color: 'var(--warn)' }}>ยังไม่ตัดจ่าย</span> : null;
    };
    const btn = (label, col, fn, title) => (
      <button onClick={fn} title={title} style={{ fontSize: 11.5, padding: '4px 9px', borderRadius: 7, cursor: 'pointer',
        fontFamily: 'inherit', border: '1px solid ' + col, background: '#fff', color: col, fontWeight: 700, whiteSpace: 'nowrap' }}>{label}</button>
    );

    /* ── สร้าง items ต่อกลุ่ม ── */
    const pairItems = (pairs) => pairs.map(p => ({
      key: 'p' + p.txn.id,
      bank: [bankSide(p.txn)], mango: (p.mangos || [p.mango]).map(mangoSide),
      checkId: p.txn.id, checkSide: 'bank',      // ติ๊กได้ เพื่อเพิ่มใบ OF เข้าไปในคู่นี้
      statusKind: p.how === 'manual' ? (p.txn.matchMode === 'confirmed' ? 'confirmed' : 'manual')
        : p.how === 'invoice' ? 'exact'
        : p.how === 'partial' ? 'partial'
        : p.how === 'combo' ? 'combo'
        : (p.weak ? 'ambiguous' : 'suggested'),
      statusSub: p.why,
      pv: pvCell(p.mango),
      _iso: p.txn.trnDate, _amt: fcNum(p.txn.amount),
      _text: [p.txn.plateNo, p.txn.driverName, p.txn.merchantName, p.txn.pp20Name, p.mango.docNo, p.mango.itemName, p.mango.vendor, p.txn.amount].join(' '),
      action: readOnly ? null : (
        <div style={{ display: 'flex', gap: 4, justifyContent: 'center' }}>
          {p.how !== 'manual' && btn('✓ ยืนยัน', 'var(--good)', () => onConfirmOne(p), 'ล็อกคู่นี้ไว้ ไม่ให้ระบบเปลี่ยนเอง')}
          {btn('🔁', 'var(--brand-600)', () => onLink(p.txn), 'จับผิดใบ — เปลี่ยนไปใช้ใบ OF อื่น')}
          {p.how === 'manual' && btn('✕', 'var(--bad)', () => onUnlink(p.txn), 'ยกเลิกการจับคู่')}
        </div>
      ),
    }));
    const bankOnlyItems = pendingRows.map(t => ({
      key: 'b' + t.id,
      bank: [bankSide(t)], mango: null,
      statusKind: 'bankOnly', statusSub: isExecProg ? 'มีในบิล · ยังไม่ตั้ง OF' : 'เติมแล้ว · ยังไม่ตั้ง OF',
      checkId: t.id, checkSide: 'bank',
      _iso: t.trnDate, _amt: fcNum(t.amount),
      _text: [t.plateNo, t.driverName, t.merchantName, t.pp20Name, t.invoiceNo, t.amount].join(' '),
      track: <FcTrackCell row={t} kind="txn" readOnly={readOnly}
        steps={t.status === 'of_pending'
          ? [{ label: '↩ ยังไม่ได้ตั้ง', color: 'var(--ink-500)', to: null }]
          : [{ label: '📝 ตั้ง OF แล้ว', color: 'var(--brand-600)', to: 'of_pending', title: 'ตั้งใน Mango แล้ว รอ export มาจับคู่' }]}
        onStatus={onTxnStatus} onNote={onTxnNote} />,
      action: readOnly ? null : (
        <div style={{ display: 'flex', gap: 4, justifyContent: 'center' }}>
          {btn('🔗', 'var(--brand-600)', () => onLink(t), 'ค้นหาใบ OF มาจับคู่')}
          {btn('🚫', 'var(--ink-500)', () => onToggleIgnore(t), 'รายการนี้ไม่ต้องตั้ง OF')}
        </div>
      ),
    }));
    const isCarry = (m) => !!(nextOfIds && nextOfIds[m.id]) || fcPayState(m) !== 'none';
    const overRows = recon.mangoOnly.filter(m => !isCarry(m));     // ตั้งเกินจริง (ยังไม่จ่าย · ไม่ใช่ข้ามรอบ)
    const carryRows = recon.mangoOnly.filter(isCarry);             // ข้ามรอบ / จ่ายแล้ว = ไม่ใช่ของค้างรอบนี้
    const mangoItem = (m) => ({
      key: 'm' + m.id,
      bank: null, mango: [mangoSide(m)],
      statusKind: (nextOfIds && nextOfIds[m.id]) ? 'nextCycle'
        : fcPayState(m) === 'full' ? 'paidClosed'
        : fcPayState(m) === 'partial' ? 'paidPartial' : 'mangoOnly',
      statusSub: (nextOfIds && nextOfIds[m.id])
        ? 'ไปตัดกับบิลรอบ ' + fcFmtMonth(nextOfIds[m.id])
        : fcPayState(m) === 'full'
          ? 'ตัดจ่ายแล้ว (' + (m.pvNo || 'PV') + ') · กระทบรอบก่อนไปแล้ว'
          : fcPayState(m) === 'partial'
            ? 'จ่าย ' + fmtMoney(m.docPaidAmount) + ' / ตั้ง ' + fmtMoney(m.docAmount) + ' — ที่เหลือยังไม่ตัด'
            : 'ตั้ง OF แล้ว · ไม่มีในบิลรอบนี้',
      pv: pvCell(m),
      checkId: m.id, checkSide: 'mango',
      _iso: m.eventDate || m.vchDate, _amt: fcNum(m.amount),
      _text: [m.docNo, m.itemName, m.vendor, m.plateNo, m.docRemark, m.amount].join(' '),
      // ขั้นตอนติดตาม: ตรวจ → เกินจริง (รอตัด) → ตัดออกแล้ว → ลบได้
      //   ใบที่ระบบเห็นว่าไปตัดบิลรอบอื่น = ไม่ต้องทำอะไร แค่ใส่หมายเหตุไว้กันงงตอนย้อนดู
      track: <FcTrackCell row={m} kind="mango" readOnly={readOnly}
        moveTo={onMoveMango ? [
          { label: '◀ ย้ายไป ' + fcFmtMonth(fcYmShift(m.ym, -1)), delta: -1, title: 'ใบนี้เป็นของรอบใบแจ้งหนี้ก่อน' },
          { label: 'ย้ายไป ' + fcFmtMonth(fcYmShift(m.ym, 1)) + ' ▶', delta: 1, title: 'ใบนี้จะไปตัดกับบิลรอบถัดไป' },
        ] : null}
        onMove={onMoveMango}
        steps={(nextOfIds && nextOfIds[m.id]) || fcPayState(m) === 'full' ? [] :
          m.status === 'over_confirmed'
            ? [{ label: '✂ ตัดออกแล้ว', color: 'var(--good)', to: 'over_cancelled', title: 'Mango ตัดรายการนี้ออกแล้ว' },
               { label: '↩', color: 'var(--ink-500)', to: null, title: 'ย้อนกลับ' }]
            : m.status === 'over_cancelled'
              ? [{ label: '↩', color: 'var(--ink-500)', to: null, title: 'ย้อนกลับ' }]
              : m.status === 'over_ok'
                ? [{ label: '↩', color: 'var(--ink-500)', to: null, title: 'ย้อนกลับ' }]
                : [{ label: '⚠ เกินจริง', color: 'var(--bad)', to: 'over_confirmed', title: 'ตรวจแล้วตั้งเกินจริง — รอตาม Mango ให้ตัดออก' },
                   { label: '✓ ไม่ใช่เกิน', color: 'var(--good)', to: 'over_ok', title: 'ตรวจแล้วเป็นรายการจริง — เก็บไว้ดูย้อนหลัง' }]}
        onStatus={onMangoStatus} onNote={onMangoNote} />,
      action: readOnly || !canDelete ? null
        : ((fcPayState(m) === 'full' || fcPayState(m) === 'partial') && !(nextOfIds && nextOfIds[m.id]))
          ? btn('🗑 ลบ', 'var(--ink-500)', () => onDeleteMango(m), 'ตัดจ่ายไปแล้ว — เอาออกจากรายการรอบนี้')
          : (m.status === 'over_cancelled'
            ? btn('🗑 ลบ', 'var(--bad)', () => onDeleteMango(m), 'ลบรายการนี้ออกจากระบบถาวร')
            : null),
    });
    const overItems = overRows.map(mangoItem);        // ตั้งเกินจริง — ต้องตาม
    const carryItems = carryRows.map(mangoItem);      // ข้ามรอบ / จ่ายแล้ว — แค่ดูย้อนหลัง
    const ignoredItems = ignoredRows.map(t => ({
      key: 'i' + t.id,
      bank: [bankSide(t)], mango: null,
      statusKind: 'ignored', statusSub: 'ทำเครื่องหมายไว้ว่าไม่ต้องตั้ง',
      _iso: t.trnDate, _amt: fcNum(t.amount),
      _text: [t.plateNo, t.driverName, t.merchantName, t.amount].join(' '),
      action: readOnly ? null : btn('นำกลับมานับ', 'var(--brand-600)', () => onToggleIgnore(t)),
    }));

    const autoPairs = recon.pairs.filter(p => p.how !== 'manual');
    const confirmedPairs = recon.pairs.filter(p => p.how === 'manual');

    const TABS = [
      { k: 'pending', label: 'รอยืนยัน', n: autoPairs.length, hint: 'ระบบจับคู่ให้ — ตรวจแล้วกดยืนยัน' },
      { k: 'unmatched', label: 'ยังไม่ตั้ง OF', n: pendingRows.length, hint: 'มีในบิลธนาคาร แต่ยังไม่มีใน Mango' },
      { k: 'over', label: 'ตั้งเกิน', n: overRows.length, hint: 'ตั้ง OF ไว้ แต่ไม่มีในบิลรอบไหนเลย — ต้องตามให้ตัดออก' },
      { k: 'carry', label: 'ข้ามรอบ / ปิดงาน', n: carryRows.length, hint: 'ไปตัดกับบิลอีกรอบ หรือจ่าย PV ไปแล้ว — ไม่ใช่ของค้างรอบนี้' },
      { k: 'matched', label: 'กระทบแล้ว', n: recon.pairs.length, hint: 'คู่ทั้งหมดที่จับได้' },
      { k: 'all', label: 'ทั้งหมด', n: recon.pairs.length + pendingRows.length + recon.mangoOnly.length, hint: '' },
    ];
    const tab = ['pending', 'unmatched', 'over', 'carry', 'matched', 'all'].indexOf(filter) >= 0 ? filter : 'pending';

    let items = [];
    if (tab === 'pending') items = pairItems(autoPairs);
    else if (tab === 'unmatched') items = bankOnlyItems;
    else if (tab === 'over') items = overItems;
    else if (tab === 'carry') items = carryItems;
    else if (tab === 'matched') items = pairItems(recon.pairs);
    else items = pairItems(recon.pairs).concat(bankOnlyItems, overItems, carryItems, ignoredItems);

    const kw = q.trim().toLowerCase();
    const shown = items
      .filter(it => !kw || String(it._text || '').toLowerCase().indexOf(kw) >= 0)
      .sort((a, b) => {
        if (sort === 'amt_desc') return Math.abs(b._amt) - Math.abs(a._amt);
        if (sort === 'amt_asc') return Math.abs(a._amt) - Math.abs(b._amt);
        const c = String(a._iso || '').localeCompare(String(b._iso || ''));
        return sort === 'date_asc' ? c : -c;
      });
    const shownAmt = shown.reduce((s, it) => s + Math.abs(it._amt || 0), 0);

    return (
      <>
        {/* แท็บกลุ่ม + ค้นหา/เรียง */}
        <div className="card" style={{ padding: '8px 10px', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          {TABS.map(t => (
            <button key={t.k} onClick={() => setFilter(t.k)} title={t.hint}
              style={{ border: 'none', cursor: 'pointer', borderRadius: 999, padding: '5px 13px', fontSize: 11.5, fontWeight: 700, fontFamily: 'inherit',
                background: tab === t.k ? 'var(--ink-800)' : 'var(--ink-100)', color: tab === t.k ? '#fff' : 'var(--ink-700)' }}>
              {t.label} <span style={{ opacity: .75 }}>({t.n})</span>
            </button>
          ))}
          <input className="input" value={q} onChange={e => setQ(e.target.value)}
            placeholder={isExecProg ? 'ค้นหา ร้านค้า / เลข OF / ยอด' : 'ค้นหา ทะเบียน / ปั๊ม / เลข OF / ยอด'}
            style={{ marginLeft: 'auto', width: 250, fontSize: 12 }} />
          <select className="select input" value={sort} onChange={e => setSort(e.target.value)} style={{ fontSize: 11.5, padding: '4px 8px' }}>
            <option value="date_asc">วันที่ เก่า→ใหม่</option>
            <option value="date_desc">วันที่ ใหม่→เก่า</option>
            <option value="amt_desc">ยอด มาก→น้อย</option>
            <option value="amt_asc">ยอด น้อย→มาก</option>
          </select>
        </div>

        {/* แถบจับคู่เอง — เลือกฝั่งบิล 1 + ฝั่ง OF 1 (ใช้ได้ในแท็บ "ยังไม่ตั้ง"/"ตั้งเกิน"/"ทั้งหมด") */}
        {!readOnly && (selBankIds.length > 0 || selMangoIds.length > 0) && (
          <div className="card" style={{ padding: '10px 14px', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
            borderLeft: '4px solid var(--brand-500)' }}>
            <div style={{ fontSize: 12.5 }}>
              เลือกไว้ — บิล <b>{selBankIds.length}</b> ({fmtMoney(selBankAmt)}) · OF <b>{linkRows.length || selMangoIds.length}</b> ({fmtMoney(selMangoAmt)})
              {keepRows.length > 0 && selMangoIds.length > 0 && (
                <span style={{ color: 'var(--brand-600)', marginLeft: 8 }}>
                  ➕ เพิ่มเข้าไปในคู่เดิม (มีอยู่แล้ว {keepRows.length} ใบ)
                </span>
              )}
              {canLink && Math.abs(selBankAmt - selMangoAmt) >= 0.01 && (
                <span style={{ color: 'var(--bad)', marginLeft: 8 }}>⚠ ยอดต่างกัน {fmtMoney(Math.abs(selBankAmt - selMangoAmt))}</span>
              )}
              {canLink && Math.abs(selBankAmt - selMangoAmt) < 0.01 && (
                <span style={{ color: 'var(--good)', marginLeft: 8, fontWeight: 700 }}>✓ ยอดตรงพอดี</span>
              )}
            </div>
            <div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
              <button className="btn btn-ghost" onClick={clearSel}>ล้างที่เลือก</button>
              {/* ลบใบ OF ที่เลือกไว้ — ใช้ตอนเช็คแล้วว่าใบไหนกระทบจบไปแล้ว/ไม่ต้องค้างในรอบนี้ */}
              {canDelete && selMangoIds.length > 0 && selBankIds.length === 0 && (
                <button className="btn" style={{ color: 'var(--bad)', borderColor: 'var(--bad)' }}
                  onClick={() => { onDeletePaid(selMangoRows); clearSel(); }}
                  title="เอาใบ OF ที่เลือกออกจากระบบ (ใช้กับใบที่ตรวจแล้วว่าไม่ต้องค้างในรอบนี้)">
                  🗑 ลบที่เลือก ({selMangoIds.length})
                </button>
              )}
              <button className="btn btn-primary" disabled={!canLink}
                onClick={() => {
                  const gap = Math.abs(selBankAmt - selMangoAmt);
                  if (gap >= 0.01 && !window.confirm(
                    'ยอดสองฝั่งไม่เท่ากัน (ต่างกัน ' + fmtMoney(gap) + ' บาท)\n\n' +
                    'บิล ' + fmtMoney(selBankAmt) + ' ↔ OF ' + fmtMoney(selMangoAmt) +
                    ' (' + linkRows.length + ' ใบ)\n\nยืนยันจับคู่?')) return;
                  onLinkPair(selBankRows[0], linkRows);
                  clearSel();
                }}
                title={canLink ? 'ผูกคู่นี้ไว้ถาวร (ใบ OF หลายใบต่อหนึ่งบิลได้)' : 'เลือกบิล 1 รายการ + ใบ OF อย่างน้อย 1 ใบ'}>
                {keepRows.length > 0 ? '➕ เพิ่มเข้าคู่เดิม' : '🔗 จับคู่ที่เลือก'}
                {selMangoIds.length > 1 ? ' (' + selMangoIds.length + ' ใบ)' : ''}
              </button>
            </div>
          </div>
        )}

        {/* ตารางคู่ซ้าย-ขวา */}
        <div className="card" style={{ overflow: 'hidden', marginBottom: 14 }}>
          <div style={{ padding: '9px 14px', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
            <div style={{ fontWeight: 700, fontSize: 13, color: 'var(--ink-800)' }}>
              {(TABS.find(t => t.k === tab) || {}).label} — {shown.length} รายการ
            </div>
            <div style={{ fontSize: 11.5, color: 'var(--ink-500)' }}>{(TABS.find(t => t.k === tab) || {}).hint}</div>
            {!readOnly && canDelete && (tab === 'carry' || tab === 'all') && paidClosed.length > 0 && (
              <button className="btn" onClick={() => onDeletePaid(paidClosed)}
                title="ใบพวกนี้ตัดจ่าย PV ไปแล้ว กระทบกับบิลรอบก่อนเรียบร้อย — เอาออกจากรายการรอบนี้"
                style={{ fontSize: 11.5, padding: '3px 10px' }}>
                🗑 เอาใบที่จ่ายแล้วออก ({paidClosed.length})
              </button>
            )}
            <div style={{ marginLeft: 'auto', fontSize: 12.5, fontWeight: 700, color: 'var(--ink-700)' }}>{fmtMoney(shownAmt)} บาท</div>
          </div>
          <FcReconTable items={shown} leftLabel={prog.label + (isExecProg ? ' (statement)' : ' (บิลธนาคาร)')}
            selectable={!readOnly && (tab === 'unmatched' || tab === 'over' || tab === 'carry' || tab === 'all')}
            selBank={selBank} selMango={selMango} onToggle={toggleSel}
            showPv={hasPayInfo}
            showTrack={tab === 'unmatched' || tab === 'over' || tab === 'carry' || tab === 'all'}
            emptyText={tab === 'unmatched' ? '✓ ไม่มีรายการค้าง — ตั้งครบทุกรายการแล้ว'
              : tab === 'over' ? '✓ ไม่มีใบ OF ที่ตั้งเกินในรอบนี้'
              : tab === 'carry' ? 'ไม่มีใบที่ข้ามรอบหรือปิดงานในรอบนี้'
              : tab === 'pending' ? 'ไม่มีคู่ที่รอยืนยัน' : 'ไม่มีรายการ'} />
        </div>

        {/* รายการที่ทำเครื่องหมาย "ไม่ต้องตั้ง" — แยกออกมาให้ไม่รก */}
        {tab !== 'all' && ignoredRows.length > 0 && (
          <div className="card" style={{ overflow: 'hidden' }}>
            <div style={{ padding: '9px 14px', background: 'var(--ink-50)', fontWeight: 700, fontSize: 12.5, color: 'var(--ink-700)' }}>
              🚫 ไม่ต้องตั้ง — {ignoredRows.length} รายการ ({fmtMoney(ignoredRows.reduce((s, r) => s + fcNum(r.amount), 0))} บาท)
            </div>
            <FcReconTable items={ignoredItems} leftLabel={prog.label} showPv={false} />
          </div>
        )}
      </>
    );
  }

  /* ── Excel: ตารางจัดสไตล์ + หลายชีต ────────────────────────────────
   *   ทำเองแทน exportRowsToExcel() เพราะไฟล์นี้ต้องมี "ชีตสรุป" ต่อท้ายรายการ
   *   (ทะเบียนไหนใช้ไปเท่าไร) และต้องอ่านง่ายพอที่จะพิมพ์แนบส่งได้เลย
   *   ใช้ xlsx-js-style ที่ index.html โหลดไว้แล้ว (global XLSX รองรับ cell.s) */
  const FC_XL = {
    head: '1A4490', headTx: 'FFFFFF', sub: '2A6FDB', zebra: 'F4F8FF',
    line: 'D6E2F5', total: 'E3EDFF', ink: '1F2937', mute: '6B7280',
    calc: '0F766E', user: 'B45309',          // หัวคอลัมน์ที่ระบบเติมให้ / ช่องที่เว้นให้ผู้ใช้พิมพ์เอง
    money: '#,##0.00', int: '#,##0', pct: '0.0%', qty: '#,##0.000',
    diff: '[Blue]+#,##0.00;[Red]-#,##0.00;"-"',   // ผลต่าง: บวก = น้ำเงิน · ลบ = แดง · ศูนย์ = ขีด (สีตามค่าจริงแม้แก้ตัวเลขทีหลัง)
  };
  const fcXlBox = (rgb) => {
    const b = { style: 'thin', color: { rgb: rgb || FC_XL.line } };
    return { top: b, bottom: b, left: b, right: b };
  };
  const fcXlFmt = (t) => t === 'number' ? FC_XL.money : t === 'int' ? FC_XL.int : t === 'pct' ? FC_XL.pct : null;

  /* คืน worksheet 1 ชีต: หัวเรื่อง → บรรทัดอธิบาย → หัวตาราง → ข้อมูล (แถบสลับสี) → แถวรวม
   *   columns: [{ key, label, type: 'text'|'number'|'int'|'pct', width }] */
  function fcXlSheet({ title, note, columns, rows, total }) {
    const nc = columns.length;
    const pad = (a) => a.concat(new Array(Math.max(0, nc - a.length)).fill(''));
    /* ค่าตัวเลขจากฐานข้อมูลเป็น numeric → มาถึงหน้าเว็บเป็น "string" ถ้าเขียนลง Excel ดิบๆ
     *   Excel จะถือเป็นข้อความ ไม่คั่นหลักพันและบวกลบไม่ได้ — ต้องแปลงเป็นตัวเลขก่อนเสมอ */
    const cellVal = (c, v) => {
      if (v == null || v === '') return '';
      return (c.type === 'number' || c.type === 'int' || c.type === 'pct') ? fcNum(v) : v;
    };
    const aoa = [pad([title]), pad([note || '']), columns.map(c => c.label)];
    rows.forEach(r => aoa.push(columns.map(c => cellVal(c, r[c.key]))));
    if (total) aoa.push(columns.map((c, i) => {
      if (total[c.key] != null) return cellVal(c, total[c.key]);
      return i === 0 ? 'รวมทั้งหมด' : '';
    }));

    const ws = XLSX.utils.aoa_to_sheet(aoa);
    const cell = (r, c) => {
      const a = XLSX.utils.encode_cell({ r: r, c: c });
      if (!ws[a]) ws[a] = { t: 's', v: '' };
      return ws[a];
    };
    for (let c = 0; c < nc; c++) {
      cell(0, c).s = { fill: { fgColor: { rgb: FC_XL.head } }, font: { bold: true, sz: 13, color: { rgb: FC_XL.headTx } },
        alignment: { vertical: 'center' } };
      cell(1, c).s = { font: { sz: 10, italic: true, color: { rgb: FC_XL.mute } }, alignment: { vertical: 'center' } };
      cell(2, c).s = { fill: { fgColor: { rgb: FC_XL.sub } }, font: { bold: true, sz: 11, color: { rgb: FC_XL.headTx } },
        alignment: { horizontal: 'center', vertical: 'center', wrapText: true }, border: fcXlBox('FFFFFF') };
    }
    rows.forEach((_, i) => columns.forEach((c, ci) => {
      const cl = cell(3 + i, ci);
      cl.s = { font: { sz: 10.5, color: { rgb: FC_XL.ink } },
        fill: { fgColor: { rgb: i % 2 ? FC_XL.zebra : 'FFFFFF' } },
        alignment: { horizontal: c.type && c.type !== 'text' ? 'right' : 'left', vertical: 'center' },
        border: fcXlBox() };
      const f = fcXlFmt(c.type); if (f) cl.z = f;
    }));
    if (total) {
      const r = 3 + rows.length;
      columns.forEach((c, ci) => {
        const cl = cell(r, ci);
        cl.s = { font: { bold: true, sz: 11, color: { rgb: FC_XL.head } }, fill: { fgColor: { rgb: FC_XL.total } },
          alignment: { horizontal: c.type && c.type !== 'text' ? 'right' : 'left', vertical: 'center' },
          border: { top: { style: 'medium', color: { rgb: FC_XL.sub } }, bottom: { style: 'double', color: { rgb: FC_XL.sub } },
            left: { style: 'thin', color: { rgb: FC_XL.line } }, right: { style: 'thin', color: { rgb: FC_XL.line } } } };
        const f = fcXlFmt(c.type); if (f) cl.z = f;
      });
    }
    ws['!cols'] = columns.map(c => ({ wch: c.width || (c.type && c.type !== 'text' ? 12 : 16) }));
    ws['!rows'] = [{ hpt: 26 }, { hpt: 16 }, { hpt: 22 }];
    ws['!merges'] = [
      { s: { r: 0, c: 0 }, e: { r: 0, c: nc - 1 } },
      { s: { r: 1, c: 0 }, e: { r: 1, c: nc - 1 } },
    ];
    ws['!autofilter'] = { ref: XLSX.utils.encode_range({ s: { r: 2, c: 0 }, e: { r: 2 + rows.length, c: nc - 1 } }) };
    return ws;
  }

  /* จัดกลุ่มเป็นตารางสรุป: คีย์ → ครั้ง/ลิตร/ยอด + สัดส่วน */
  function fcXlSummary(rows, keyOf, extra) {
    const map = {};
    rows.forEach(r => {
      const k = keyOf(r) || 'ไม่ระบุ';
      const g = map[k] || (map[k] = { key: k, count: 0, litre: 0, amount: 0, rows: [] });
      g.count += 1; g.litre += fcNum(r.qtyLitre); g.amount += fcNum(r.amount); g.rows.push(r);
    });
    const all = Object.keys(map).map(k => map[k]);
    const sum = all.reduce((s, g) => s + g.amount, 0) || 1;
    all.forEach(g => {
      g.share = g.amount / sum;
      g.litre = Math.round(g.litre * 1000) / 1000;
      g.amount = Math.round(g.amount * 100) / 100;
      if (extra) Object.assign(g, extra(g));
    });
    return all.sort((a, b) => b.amount - a.amount);
  }

  /* ไฟล์ Excel ของแท็บรายการ: ชีตรายการ + ชีตสรุป (ทะเบียน/แผนก หรือ ผู้ถือบัตร/ร้านค้า) */
  function fcExportTxnsExcel(rows, program, ym, pairOf) {
    if (typeof XLSX === 'undefined') { alert('ระบบ Export ยังไม่พร้อม — ลองรีเฟรชหน้า'); return; }
    if (!rows.length) { alert('ไม่มีรายการให้ส่งออก'); return; }
    const isExec = program === 'exec';
    const label = fcProgInfo(program).label;
    const period = fcFmtMonth(ym);
    const amt = rows.reduce((s, r) => s + fcNum(r.amount), 0);
    const litre = rows.reduce((s, r) => s + fcNum(r.qtyLitre), 0);
    // ข้อมูลการจับคู่ของแต่ละรายการ — ตั้งเป็นใบ OF ไหน ตัดจ่ายหรือยัง
    const pair = pairOf || {};
    const ofInfo = (t) => {
      const p = pair[t.id];
      if (t.status === 'ignored') return { st: 'ไม่ต้องตั้ง', of: '', ofAmt: null };
      if (!p) return { st: 'ยังไม่ตั้ง OF', of: '', ofAmt: null };
      const ms = (p.mangos || [p.mango]).filter(Boolean);
      return {
        st: 'ตั้งแล้ว',
        of: ms.map(m => m.docNo).filter(Boolean).join(', '),
        ofAmt: Math.round(ms.reduce((s2, m) => s2 + fcNum(m.amount), 0) * 100) / 100,
      };
    };
    const note = 'ออกจากระบบ Water POG Financial Console เมื่อ ' + new Date().toLocaleString('th-TH') +
      ' · ทั้งหมด ' + rows.length + ' รายการ' + (isExec ? '' : ' · ' + fmtNum(litre, 2) + ' ลิตร');
    const wb = XLSX.utils.book_new();

    /* ชีต 1 — รายการทั้งหมด */
    const detailCols = isExec ? [
      { key: 'trnDate', label: 'วันที่รูด', width: 12 },
      { key: 'postDate', label: 'วันที่ลงบัญชี', width: 12 },
      { key: 'merchantName', label: 'ร้านค้า', width: 34 },
      { key: 'driverName', label: 'ผู้ถือบัตร', width: 24 },
      { key: 'cardNo', label: 'เลขบัตร', width: 22 },
      { key: 'amount', label: 'จำนวนเงิน', type: 'number', width: 14 },
      { key: 'ofStatus', label: 'สถานะ', width: 12 },
      { key: 'ofNo', label: 'เลขที่ OF', width: 16 },
      { key: 'ofAmt', label: 'ยอดตาม OF', type: 'number', width: 13 },
      { key: 'statementDate', label: 'วันสรุปยอด', width: 12 },
      { key: 'dueDate', label: 'ครบกำหนดชำระ', width: 13 },
    ] : [
      { key: 'trnDate', label: 'วันที่เติม', width: 11 },
      { key: 'trnTime', label: 'เวลา', width: 8 },
      { key: 'invoiceNo', label: 'เลขใบกำกับ', width: 22 },
      { key: 'plateNo', label: 'ทะเบียน', width: 12 },
      { key: 'driverName', label: 'ผู้ถือบัตร', width: 22 },
      { key: 'deptCode', label: 'แผนก', width: 9 },
      { key: 'merchantName', label: 'ปั๊ม', width: 28 },
      { key: 'productName', label: 'ชนิดน้ำมัน', width: 14 },
      { key: 'qtyLitre', label: 'ลิตร', type: 'number', width: 10 },
      { key: 'priceRate', label: 'ราคา/ลิตร', type: 'number', width: 10 },
      { key: 'exVatAmount', label: 'ก่อน VAT', type: 'number', width: 12 },
      { key: 'vatAmount', label: 'VAT', type: 'number', width: 11 },
      { key: 'amount', label: 'ยอดรวม', type: 'number', width: 13 },
      { key: 'ofStatus', label: 'สถานะ', width: 12 },
      { key: 'ofNo', label: 'เลขที่ OF', width: 16 },
      { key: 'ofAmt', label: 'ยอดตาม OF', type: 'number', width: 13 },
    ];
    // เรียงตามชื่อ (ทะเบียน / ผู้ถือบัตร) แล้วค่อยวันที่ — อ่านเป็นกลุ่มเดียวกันง่ายกว่าเรียงวันที่ล้วน
    const nameOf = (t) => isExec ? (t.driverName || t.merchantName || '') : (t.plateNo || t.driverName || '');
    const detailRows = rows.slice()
      .sort((a, b) => String(nameOf(a)).localeCompare(String(nameOf(b)), 'th') ||
        String(a.trnDate).localeCompare(String(b.trnDate)))
      .map(t => {
        const o = ofInfo(t);
        return Object.assign({}, t, {
          trnTime: t.trnTime ? String(t.trnTime).slice(0, 5) : '',
          deptCode: fcDeptOf(t) || '',
          merchantName: isExec ? t.merchantName : (t.pp20Name || t.merchantName),
          ofStatus: o.st, ofNo: o.of, ofAmt: o.ofAmt,
        });
      });
    XLSX.utils.book_append_sheet(wb, fcXlSheet({
      title: (isExec ? 'รายการใช้จ่ายบัตร' : 'รายการเติมน้ำมัน') + ' · ' + label + ' · รอบ ' + period,
      note: note, columns: detailCols, rows: detailRows,
      total: { amount: amt, qtyLitre: isExec ? null : litre },
    }), isExec ? 'รายการใช้จ่าย' : 'รายการเติมน้ำมัน');

    /* ชีต 2-3 — สรุป */
    const shareCol = { key: 'share', label: 'สัดส่วน', type: 'pct', width: 10 };
    const cntCol = { key: 'count', label: 'จำนวนครั้ง', type: 'int', width: 10 };
    const amtCol = { key: 'amount', label: 'ยอดเงิน (บาท)', type: 'number', width: 15 };
    const doneCol = { key: 'ofDone', label: 'ตั้ง OF แล้ว', type: 'number', width: 13 };
    const waitCol = { key: 'ofWait', label: 'ยังไม่ตั้ง', type: 'number', width: 12 };
    // ยอดที่ตั้ง OF แล้ว / ยังค้าง ของแต่ละกลุ่ม — ดูได้ทันทีว่าทะเบียนไหนยังไม่จบ
    const ofSplit = (g) => {
      let done = 0, wait = 0;
      g.rows.forEach(r => { const v = fcNum(r.amount); if (pair[r.id]) done += v; else if (r.status !== 'ignored') wait += v; });
      return { ofDone: Math.round(done * 100) / 100, ofWait: Math.round(wait * 100) / 100 };
    };
    const doneAmt = rows.reduce((s2, r) => s2 + (pair[r.id] ? fcNum(r.amount) : 0), 0);
    const waitAmt = rows.reduce((s2, r) => s2 + (!pair[r.id] && r.status !== 'ignored' ? fcNum(r.amount) : 0), 0);
    const totalMore = { ofDone: Math.round(doneAmt * 100) / 100, ofWait: Math.round(waitAmt * 100) / 100 };
    if (isExec) {
      const byHolder = fcXlSummary(rows, r => r.driverName, g => Object.assign({ cardNo: (g.rows[0] || {}).cardNo || '' }, ofSplit(g)));
      XLSX.utils.book_append_sheet(wb, fcXlSheet({
        title: 'สรุปรายผู้ถือบัตร · ' + label + ' · รอบ ' + period, note: note,
        columns: [{ key: 'key', label: 'ผู้ถือบัตร', width: 28 }, { key: 'cardNo', label: 'เลขบัตร', width: 22 },
          cntCol, amtCol, doneCol, waitCol, shareCol],
        rows: byHolder, total: Object.assign({ count: rows.length, amount: amt, share: 1 }, totalMore),
      }), 'สรุปรายผู้ถือบัตร');
      const byShop = fcXlSummary(rows, r => r.merchantName, ofSplit);
      XLSX.utils.book_append_sheet(wb, fcXlSheet({
        title: 'สรุปรายร้านค้า · ' + label + ' · รอบ ' + period, note: note,
        columns: [{ key: 'key', label: 'ร้านค้า', width: 40 }, cntCol, amtCol, doneCol, waitCol, shareCol],
        rows: byShop, total: Object.assign({ count: rows.length, amount: amt, share: 1 }, totalMore),
      }), 'สรุปรายร้านค้า');
    } else {
      const litreCol = { key: 'litre', label: 'ลิตร', type: 'number', width: 11 };
      const byPlate = fcXlSummary(rows, r => r.plateNo, g => Object.assign({
        driver: (g.rows.find(r => r.driverName) || {}).driverName || '',
        dept: fcDeptOf(g.rows[0] || {}) || '',
        avg: g.litre ? Math.round(g.amount / g.litre * 100) / 100 : 0,
      }, ofSplit(g)));
      XLSX.utils.book_append_sheet(wb, fcXlSheet({
        title: 'สรุปรายทะเบียนรถ · ' + label + ' · รอบ ' + period, note: note,
        columns: [{ key: 'key', label: 'ทะเบียน', width: 13 }, { key: 'driver', label: 'ผู้ถือบัตร', width: 24 },
          { key: 'dept', label: 'แผนก', width: 9 }, cntCol, litreCol,
          { key: 'avg', label: 'บาท/ลิตร', type: 'number', width: 11 }, amtCol, doneCol, waitCol, shareCol],
        rows: byPlate,
        total: Object.assign({ count: rows.length, litre: Math.round(litre * 1000) / 1000,
          amount: Math.round(amt * 100) / 100, share: 1, avg: litre ? Math.round(amt / litre * 100) / 100 : 0 }, totalMore),
      }), 'สรุปรายทะเบียน');
      const byDept = fcXlSummary(rows, r => fcDeptOf(r), ofSplit);
      XLSX.utils.book_append_sheet(wb, fcXlSheet({
        title: 'สรุปรายแผนก · ' + label + ' · รอบ ' + period, note: note,
        columns: [{ key: 'key', label: 'แผนก', width: 14 }, cntCol, litreCol, amtCol, doneCol, waitCol, shareCol],
        rows: byDept, total: Object.assign({ count: rows.length, litre: Math.round(litre * 1000) / 1000,
          amount: Math.round(amt * 100) / 100, share: 1 }, totalMore),
      }), 'สรุปรายแผนก');
    }

    XLSX.writeFile(wb, 'fleetcard_' + program + '_txns_' + ym + '_' +
      new Date().toISOString().slice(0, 10) + '.xlsx');
    try {
      if (window.WTPData && WTPData.logEvent) {
        WTPData.logEvent('export', '', 'ส่งออกรายการใช้บัตร ' + label + ' รอบ ' + period + ' (' + rows.length + ' แถว)');
      }
    } catch (_) {}
  }

  /* ═════════════════════════════════════════════════════════════════════
   * Excel "ไฟล์ DATA" — โครงเดียวกับไฟล์งานประจำเดือนของฝ่ายการเงิน (DATA ด.MM.YYYY.xlsx)
   *   สรุป             BANK ↔ OF รายฝ่าย / รายทะเบียน + ช่อง ติดตาม / ปัญหา
   *                    ตัวเลขเป็นสูตรสด (SUMIFS ไปที่ชีต DATA / OF-Mango) → แก้หรือวางแถวเพิ่มแล้วยอดตาม
   *   DATA             ฟอร์มตั้ง OF ของ Mango 39 คอลัมน์ · หัวอยู่แถว 1 ชื่อ+ลำดับตามฟอร์มจริง ก๊อปวางได้เลย
   *                    (คอลัมน์ที่ไม่ใช้พับเก็บไว้เหมือนต้นฉบับ) + คอลัมน์ช่วยต่อท้าย
   *   FORM BANK        รายการจากไฟล์ธนาคาร + ฝ่าย / Description + ผลกระทบรายรายการ
   *   OF-Mango         บรรทัด OF ที่นับในรอบนี้ = ที่จับคู่กับบิลรอบนี้ + ที่ตั้งเกินจริง
   *   นอกรอบ (ไม่นับ)   ใบ OF ของรอบนี้ที่ไม่ใช่งานของบิลรอบนี้ (รอตัดรอบถัดไป / ตัดจ่ายปิดงานแล้ว ฯลฯ)
   *   Dep.             ผู้ถือบัตร → ฝ่าย (คอลัมน์เรียงแบบที่ fcParseDepSheet อ่านกลับได้)
   * ทะเบียนฝั่ง OF: ใบที่จับคู่แล้วใช้ทะเบียนของบิลคู่มัน — itemname ใน Mango พิมพ์มือไม่เหมือนกันสักใบ
   *   (ไฟล์เดิมต้องนั่งพิมพ์เลขท้ายทะเบียนเองทีละแถว) · ใบที่ไม่มีคู่ถอดจากข้อความเท่าที่ได้
   * ชีต DATA / OF-Mango ห้ามมีแถวหัวเรื่องหรือแถวรวม — สูตรในชีตสรุปอ้างทั้งคอลัมน์
   * ══════════════════════════════════════════════════════════════════ */
  // หัวฟอร์มตั้ง OF ของ Mango — ชื่อและลำดับต้องตรงฟอร์มจริง ("Brach No" สะกดตามต้นฉบับ)
  const FC_OF_FORM = ['Expense Code', 'Cost Code', 'Company', 'Address (1)', 'Address (2)', 'Brach No',
    'Description', 'Ref. Asset', 'QTY', 'Mileage', 'Next Date', 'INVOICE_NO', 'Inv. Date', 'Tax Invoice',
    'Tax No', 'Tax Date', 'Tax ID', 'Personal ID', 'Amount', 'Vat', 'Vat Amount', 'W/T', 'Tax ID', 'Tax Type',
    'Amount W/T', 'ID No.', 'Paid Date W/T', 'W/T No', 'W/T Type', 'W/T (%)', 'W/T Amt.', 'Vender W/T',
    'Branch W/T', 'W/T Address (1)', 'W/T Address (2)', 'Net. Amt.', 'Deduct. Sub', 'Deduct Amount', 'Employee Code'];
  // คอลัมน์ของฟอร์มที่โชว์ → ความกว้าง · ที่เหลือพับ (จัดกลุ่ม) ไว้เหมือนไฟล์ต้นฉบับ กด [+] กางออกได้
  const FC_OF_FORM_W = { 0: 9, 2: 32, 3: 48, 5: 9, 6: 62, 11: 21, 12: 11, 18: 13 };
  const FC_OF_AMT = 18;                       // คอลัมน์ Amount (S)
  const FC_ETAX_URL = 'https://pttor-buyer.etaxsolution.com/app/tables/document';

  const fcDMY = (iso, sep) => {
    const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''));
    return m ? m[3] + sep + m[2] + sep + m[1] : '';
  };
  // ISO → เลขวันที่ของ Excel (เขียนเป็นตัวเลข + รูปแบบวันที่ — เลี่ยงเรื่อง timezone ของเซลล์ชนิด Date)
  const fcXlSerial = (iso) => {
    const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''));
    return m ? Math.round((Date.UTC(+m[1], +m[2] - 1, +m[3]) - Date.UTC(1899, 11, 30)) / 86400000) : null;
  };
  const fcOfDesc = (t) => ('ค่าน้ำมัน Fleet Card ฝ่าย ' + (fcDeptOf(t).toUpperCase() || '-') + ' ' + fcTrim(t.driverName) +
    ' ทะเบียนรถ ' + fcTrim(t.plateNo)).replace(/\s+/g, ' ').trim();
  // 1 รายการในบิล → 1 แถวของฟอร์ม (ช่องที่ต้นฉบับเว้นว่าง = null)
  function fcOfFormRow(t) {
    const row = new Array(FC_OF_FORM.length).fill(null);
    const addr = fcTrim(t.pp20Address) || fcTrim(t.merchantLocation);
    row[0] = 'E076';
    row[2] = fcTrim(t.pp20Name) || fcTrim(t.merchantName);
    row[3] = addr; row[4] = addr;
    row[5] = fcTrim(t.branchNo);
    row[6] = fcOfDesc(t);
    row[11] = fcTrim(t.invoiceNo);
    row[12] = fcDMY(t.postDate || t.trnDate, '-');     // ต้นฉบับใช้ POST_DATE เป็นข้อความ DD-MM-YYYY
    row[FC_OF_AMT] = fcNum(t.amount);
    return row;
  }

  const fcXlHead = (rgb, sz) => ({ fill: { fgColor: { rgb: rgb } }, font: { bold: true, sz: sz || 10.5, color: { rgb: 'FFFFFF' } },
    alignment: { horizontal: 'center', vertical: 'center', wrapText: true }, border: fcXlBox('FFFFFF') });
  const fcXlBody = (i, align, more) => Object.assign({ font: { sz: 10.5, color: { rgb: FC_XL.ink } },
    fill: { fgColor: { rgb: i % 2 ? FC_XL.zebra : 'FFFFFF' } },
    alignment: { horizontal: align || 'left', vertical: 'center' }, border: fcXlBox() }, more || {});

  /* ชีตตารางดิบ "หัวอยู่แถว 1" — ไม่มีแถวหัวเรื่อง/แถวรวม
   *   columns: [{ label, get(row), type: 'text'|'number'|'qty'|'int'|'date', width, center,
   *               calc: คอลัมน์ที่ระบบเติมให้ (หัวคนละสี), fold: พับเก็บ + ช่องว่างไม่เขียนเซลล์ }] */
  function fcXlRawSheet(columns, rows) {
    const ws = {};
    const at = (r, c) => XLSX.utils.encode_cell({ r: r, c: c });
    const FMT = { number: FC_XL.money, qty: FC_XL.qty, int: FC_XL.int, date: 'dd-mm-yyyy' };
    columns.forEach((c, ci) => {
      ws[at(0, ci)] = { t: 's', v: c.label, s: fcXlHead(c.calc ? FC_XL.calc : FC_XL.head) };
    });
    rows.forEach((row, ri) => columns.forEach((c, ci) => {
      const raw = c.get(row);
      const blank = raw == null || raw === '';
      if (blank && c.fold) return;
      const z = FMT[c.type];
      let cell;
      if (blank) cell = { t: 's', v: '' };
      else if (c.type === 'date') {
        const n = fcXlSerial(raw);
        cell = n == null ? { t: 's', v: String(raw) } : { t: 'n', v: n, z: z };
      } else if (z) cell = { t: 'n', v: fcNum(raw), z: z };
      else cell = { t: 's', v: String(raw) };
      cell.s = fcXlBody(ri, c.type === 'date' || c.center ? 'center' : z ? 'right' : 'left',
        cell.t === 'n' ? { numFmt: z } : null);
      ws[at(1 + ri, ci)] = cell;
    }));
    ws['!ref'] = XLSX.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: rows.length, c: columns.length - 1 } });
    ws['!cols'] = columns.map(c => c.fold ? { wch: c.width || 10, hidden: true, level: 1 } : { wch: c.width || 14 });
    ws['!rows'] = [{ hpt: 30 }];
    ws['!autofilter'] = { ref: ws['!ref'] };
    return ws;
  }

  /* ชีต "สรุป" — M = { title, note, countText, skipText, bankAmt, ofAmt, depts[], plates[], col }
   *   col = ตัวอักษรคอลัมน์ในชีต DATA / OF-Mango ที่สูตรอ้างถึง */
  function fcDataSummarySheet(M) {
    const X = FC_XL, NC = 9, K = M.col;
    const r2 = (n) => Math.round(fcNum(n) * 100) / 100;
    const ws = {}, merges = [], heights = [];
    const put = (r, c, v, s, more) => {
      const cell = typeof v === 'number' ? { t: 'n', v: v } : { t: 's', v: v == null ? '' : String(v) };
      if (s) cell.s = s;
      if (more) Object.assign(cell, more);
      ws[XLSX.utils.encode_cell({ r: r, c: c })] = cell;
    };
    const span = (r, c0, c1, v, s, more) => {
      for (let c = c0; c <= c1; c++) put(r, c, c === c0 ? v : '', s, c === c0 ? more : null);
      if (c1 > c0) merges.push({ s: { r: r, c: c0 }, e: { r: r, c: c1 } });
    };
    const num = (r, c, v, s, f, fmt) => put(r, c, v, Object.assign({}, s, { numFmt: fmt || X.money }),
      { f: f, z: fmt || X.money });
    const whole = (sheet, col) => sheet + '!$' + col + ':$' + col;
    const DATA = 'DATA', OFM = "'OF-Mango'";

    const sTh = fcXlHead(X.sub, 11), sThUser = fcXlHead(X.user, 11);
    const sBand = { fill: { fgColor: { rgb: X.total } }, font: { bold: true, sz: 11.5, color: { rgb: X.head } },
      alignment: { vertical: 'center' } };
    const sNote = { font: { sz: 10, italic: true, color: { rgb: X.mute } }, alignment: { vertical: 'center' } };
    const sBig = { font: { bold: true, sz: 13, color: { rgb: X.head } }, alignment: { horizontal: 'right', vertical: 'center' },
      border: fcXlBox(X.sub) };
    const sTot = (align) => ({ font: { bold: true, sz: 11, color: { rgb: X.head } }, fill: { fgColor: { rgb: X.total } },
      alignment: { horizontal: align || 'left', vertical: 'center' },
      border: { top: { style: 'medium', color: { rgb: X.sub } }, bottom: { style: 'double', color: { rgb: X.sub } },
        left: { style: 'thin', color: { rgb: X.line } }, right: { style: 'thin', color: { rgb: X.line } } } });
    const bold = (s) => Object.assign({}, s, { font: Object.assign({}, s.font, { bold: true }) });
    const wrap = (s) => Object.assign({}, s, { alignment: Object.assign({}, s.alignment, { wrapText: true }) });

    let r = 0;
    span(r, 0, NC - 1, M.title, { fill: { fgColor: { rgb: X.head } }, font: { bold: true, sz: 14, color: { rgb: 'FFFFFF' } },
      alignment: { vertical: 'center' } });
    heights[r] = { hpt: 28 }; r++;
    span(r, 0, NC - 1, M.note, sNote); r++;
    span(r, 0, NC - 1, 'E-TAX ใบกำกับภาษีของปั๊ม PTT OR → ' + FC_ETAX_URL,
      { font: { sz: 10, underline: true, color: { rgb: X.sub } }, alignment: { vertical: 'center' } },
      { l: { Target: FC_ETAX_URL, Tooltip: 'เปิดหน้าดาวน์โหลดใบกำกับภาษีอิเล็กทรอนิกส์' } });
    r += 2;

    /* ยอดรวมทั้งรอบ */
    span(r, 0, 3, 'ยอดรวมทั้งรอบ', sTh); put(r, 4, 'BANK', sTh); put(r, 5, 'OF', sTh); put(r, 6, 'DIFF', sTh);
    span(r, 7, 8, 'วิธีอ่าน', sTh);
    heights[r] = { hpt: 22 }; r++;
    const nAll = r + 1;
    const sInfo = { font: { sz: 10.5, color: { rgb: X.ink } }, alignment: { vertical: 'center', wrapText: true }, border: fcXlBox(X.sub) };
    span(r, 0, 3, M.countText, sInfo);
    num(r, 4, r2(M.bankAmt), sBig, 'SUM(' + whole(DATA, K.amt) + ')');
    num(r, 5, r2(M.ofAmt), sBig, 'SUM(' + whole(OFM, K.ofAmt) + ')');
    num(r, 6, r2(M.ofAmt - M.bankAmt), sBig, 'F' + nAll + '-E' + nAll, X.diff);
    span(r, 7, 8, 'DIFF = OF − BANK   ·   ติดลบ = ตั้งขาด   ·   บวก = ตั้งเกิน', bold(sInfo));
    heights[r] = { hpt: 28 }; r += 2;

    /* ตารางเทียบ 1 ก้อน: แถว (สูตร SUMIFS/COUNTIFS ตามคีย์ในคอลัมน์ของแถวนั้น) → แถวรวม
     *   crit = { bank: [[คอลัมน์ในชีต DATA, คอลัมน์คีย์ในชีตนี้], …], of: [[คอลัมน์ในชีต OF-Mango, คอลัมน์คีย์], …] } */
    const table = (rows, crit, eachRow) => {
      const first = r + 1;
      const cond = (sheet, pairs, n) => pairs.map(p => whole(sheet, p[0]) + ',$' + p[1] + n).join(',');
      rows.forEach((g, i) => {
        const n = r + 1;
        eachRow(g, i);
        put(r, 3, g.count, fcXlBody(i, 'right', { numFmt: X.int }),
          { f: 'COUNTIFS(' + cond(DATA, crit.bank, n) + ')', z: X.int });
        num(r, 4, r2(g.bank), fcXlBody(i, 'right'), 'SUMIFS(' + whole(DATA, K.amt) + ',' + cond(DATA, crit.bank, n) + ')');
        num(r, 5, r2(g.of), fcXlBody(i, 'right'), 'SUMIFS(' + whole(OFM, K.ofAmt) + ',' + cond(OFM, crit.of, n) + ')');
        num(r, 6, r2(g.of - g.bank), bold(fcXlBody(i, 'right')), 'F' + n + '-E' + n, X.diff);
        r++;
      });
      const last = r, n = r + 1;
      const sum = (col) => 'SUM(' + col + first + ':' + col + last + ')';
      span(r, 0, 2, 'รวม', sTot());
      put(r, 3, rows.reduce((s, g) => s + g.count, 0), Object.assign(sTot('right'), { numFmt: X.int }), { f: sum('D'), z: X.int });
      num(r, 4, r2(rows.reduce((s, g) => s + g.bank, 0)), sTot('right'), sum('E'));
      num(r, 5, r2(rows.reduce((s, g) => s + g.of, 0)), sTot('right'), sum('F'));
      num(r, 6, r2(rows.reduce((s, g) => s + g.of - g.bank, 0)), sTot('right'), 'F' + n + '-E' + n, X.diff);
      span(r, 7, 8, '', sTot());
      r++;
    };
    const red = (text) => text ? { font: { sz: 10.5, color: { rgb: 'B91C1C' } } } : null;

    /* รายฝ่าย — ฝั่ง BANK = แผนกที่ใช้บัตร (ชุดเดียวกับคอลัมน์ ฝ่าย ในตารางรายทะเบียนข้างล่าง รวมกันได้ยอดเท่ากัน)
     *   ฝั่ง OF = ฝ่ายที่ตั้งเบิกใน Mango → ต่างกันเมื่อบิลของฝ่ายหนึ่งไปตั้ง OF อีกฝ่าย ช่อง "ที่มาของผลต่าง" บอกว่าไปไหน */
    span(r, 0, NC - 1, 'เทียบรายฝ่าย   ( BANK = แผนกที่ใช้บัตร  ·  OF = ฝ่ายที่ตั้งเบิกใน Mango )', sBand);
    heights[r] = { hpt: 22 }; r++;
    put(r, 0, 'ฝ่าย', sTh); span(r, 1, 2, 'ชื่อฝ่าย', sTh); put(r, 3, 'ครั้ง', sTh);
    put(r, 4, 'BANK', sTh); put(r, 5, 'OF', sTh); put(r, 6, 'DIFF', sTh); span(r, 7, 8, 'ที่มาของผลต่าง', sTh);
    heights[r] = { hpt: 22 }; r++;
    table(M.depts, { bank: [[K.dept, 'A']], of: [[K.ofDept, 'A']] }, (g, i) => {
      put(r, 0, g.key, bold(fcXlBody(i, 'center')));
      span(r, 1, 2, g.name, fcXlBody(i));
      span(r, 7, 8, g.note, wrap(fcXlBody(i, 'left', red(g.note))));
    });
    r++;

    /* รายทะเบียน — 1 แถว = 1 ทะเบียนต่อ 1 แผนกที่ใช้ (บัตร VIP ที่หลายแผนกเบิกไปใช้ แตกเป็นแผนกละแถว) */
    span(r, 0, NC - 1, 'เทียบรายทะเบียน   ( แยกตามแผนกที่ใช้บัตร  ·  OF นับตามบิลที่จับคู่ได้ )', sBand);
    heights[r] = { hpt: 22 }; r++;
    ['ฝ่าย', 'ทะเบียน', 'ผู้ถือบัตร', 'ครั้ง', 'BANK', 'OF', 'DIFF'].forEach((h, c) => put(r, c, h, sTh));
    put(r, 7, 'ติดตาม', sThUser); put(r, 8, 'ปัญหา', sThUser);
    heights[r] = { hpt: 22 }; r++;
    table(M.plates, { bank: [[K.plate, 'B'], [K.dept, 'A']], of: [[K.ofPlate, 'B'], [K.ofBillDept, 'A']] }, (g, i) => {
      put(r, 0, g.dept, bold(fcXlBody(i, 'center')));
      put(r, 1, g.key, bold(fcXlBody(i)));
      put(r, 2, g.driver, fcXlBody(i));
      put(r, 7, g.follow, wrap(fcXlBody(i)));
      put(r, 8, g.issue, wrap(fcXlBody(i, 'left', red(g.issue))));
    });
    r++;
    span(r, 0, NC - 1, 'ยอด BANK / OF เป็นสูตรอ้างชีต DATA และ OF-Mango · ช่อง "ติดตาม" / "ปัญหา" พิมพ์ต่อได้เลย', sNote);
    r++;
    span(r, 0, NC - 1, 'จะเปลี่ยนแผนกของรายการไหน ให้แก้ที่ปุ่ม "ระบุแผนก" ในระบบแล้วดาวน์โหลดใหม่ — พิมพ์ทับในช่อง ฝ่าย ของตารางนี้ ยอดจะไม่ตาม', sNote);
    r++;
    if (M.skipText) { span(r, 0, NC - 1, M.skipText, sNote); r++; }

    ws['!ref'] = XLSX.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: r - 1, c: NC - 1 } });
    ws['!cols'] = [10, 15, 26, 8, 15, 15, 15, 30, 58].map(w => ({ wch: w }));
    ws['!merges'] = merges;
    ws['!rows'] = heights;
    return ws;
  }

  /* เขียนไฟล์ .xlsx + เติมสิ่งที่ xlsx-js-style เขียนให้ไม่ได้ (ตั้ง ws['!freeze'] ไปก็ถูกทิ้งเงียบๆ — ลองแล้ว):
   *   ตรึงหัวตาราง · สีแท็บชีต · ปุ่ม [+] ของคอลัมน์ที่พับไว้
   *   วิธีเดียวกับ xlsxWriteWithDropdowns (page_debt.jsx): แกะ zip ด้วย XLSX.CFB → แก้ XML ของชีต → ประกอบกลับ
   *   ลำดับ element ต้องตาม schema (sheetPr → dimension → sheetViews → sheetFormatPr → cols) ไม่งั้น Excel ฟ้องไฟล์เสีย
   *   พังตรงไหนก็ถอยไป XLSX.writeFile ธรรมดา — ได้ไฟล์ที่ไม่ตรึงหัว ดีกว่าไม่ได้ไฟล์
   *   extras = { '<ชื่อชีต>': { freeze: จำนวนแถวหัวที่ตรึง, tab: 'RRGGBB' } } */
  function fcXlsxDownload(wb, filename, extras) {
    const CFB = XLSX.CFB;
    try {
      if (!CFB || !CFB.utils || !CFB.utils.cfb_add) throw new Error('ไม่มี XLSX.CFB');
      const cfb = CFB.read(XLSX.write(wb, { type: 'base64', bookType: 'xlsx' }), { type: 'base64' });
      wb.SheetNames.forEach((name, idx) => {
        const ex = (extras || {})[name] || {};
        const path = '/xl/worksheets/sheet' + (idx + 1) + '.xml';
        const ent = CFB.find(cfb, path);
        if (!ent) return;
        let xml = new TextDecoder('utf-8').decode(new Uint8Array(ent.content));
        if (ex.tab && xml.indexOf('<sheetPr') < 0) {
          xml = xml.replace('<dimension', '<sheetPr><tabColor rgb="FF' + ex.tab + '"/></sheetPr><dimension');
        }
        if (ex.freeze) {
          const cell = 'A' + (ex.freeze + 1);
          xml = xml.replace(/<sheetView ([^>]*?)\/>/, '<sheetView $1><pane ySplit="' + ex.freeze + '" topLeftCell="' + cell +
            '" activePane="bottomLeft" state="frozen"/><selection pane="bottomLeft" activeCell="' + cell + '" sqref="' + cell + '"/></sheetView>');
        }
        // คอลัมน์ที่พับ: ตัด attribute level ที่ไม่มีใน schema ออก + ทำเครื่องหมายคอลัมน์ถัดจากกลุ่มว่า "พับอยู่" (ปุ่ม [+])
        let folded = false;
        xml = xml.replace(/<cols>[\s\S]*?<\/cols>/, (cols) => {
          let prev = false;
          return cols.replace(/<col ([^>]*?)\/>/g, (_m, attrs) => {
            const grp = /outlineLevel="/.test(attrs) && /hidden="/.test(attrs);
            let a = attrs.replace(/\s*\blevel="\d+"/, '');
            if (grp) folded = true;
            else if (prev) a += ' collapsed="1"';
            prev = grp;
            return '<col ' + a + '/>';
          });
        });
        if (folded && xml.indexOf('<sheetFormatPr') < 0) {
          xml = xml.replace('</sheetViews>', '</sheetViews><sheetFormatPr defaultRowHeight="15" outlineLevelCol="1"/>');
        }
        CFB.utils.cfb_add(cfb, path, new TextEncoder().encode(xml));
      });
      const bin = atob(CFB.write(cfb, { fileType: 'zip', type: 'base64', compression: true }));
      const bytes = new Uint8Array(bin.length);
      for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }));
      const a = document.createElement('a');
      a.href = url; a.download = filename; document.body.appendChild(a); a.click();
      document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000);
    } catch (e) {
      console.warn('[FleetCard] จัดรูปแบบไฟล์เพิ่มไม่สำเร็จ → ดาวน์โหลดแบบพื้นฐานแทน:', e && e.message);
      XLSX.writeFile(wb, filename);
    }
  }

  /* P = { ym, txns: บิลของรอบ (เฉพาะรายการใช้จ่าย), recon, cross } — ใช้ผลจับคู่ชุดเดียวกับที่หน้าจอแสดง */
  function fcExportDataWorkbook(P) {
    if (typeof XLSX === 'undefined') { alert('ระบบ Export ยังไม่พร้อม — ลองรีเฟรชหน้า'); return; }
    const ym = P.ym, txns = P.txns || [];
    const recon = P.recon || { pairs: [], txnOnly: [], mangoOnly: [] };
    if (!txns.length) { alert('รอบนี้ยังไม่มีรายการจากไฟล์ธนาคาร — นำเข้าไฟล์ธนาคารก่อน'); return; }
    const nextOf = (P.cross && P.cross.nextOfIds) || {};
    const r2 = (n) => Math.round(fcNum(n) * 100) / 100;
    const sumOf = (arr, f) => r2(arr.reduce((s, x) => s + fcNum(f(x)), 0));
    const uniq = (arr) => arr.filter((v, i) => v && arr.indexOf(v) === i);
    const NA_DEPT = 'ไม่ระบุ', NA_PLATE = '(ไม่ระบุทะเบียน)';
    // คีย์ที่สูตร SUMIFS ใช้จับ — Excel เทียบแบบไม่สนตัวพิมพ์ ('sod' = 'SOD') จึงรวมเป็นตัวพิมพ์ใหญ่ให้เป็นกลุ่มเดียวตั้งแต่ต้น
    const plateKey = (s) => fcTrim(s).replace(/\s+/g, ' ').toUpperCase();
    const deptKey = (s) => fcTrim(s).toUpperCase() || NA_DEPT;
    const HOW ={ manual: 'ยืนยันเอง', invoice: 'เลขใบกำกับ', combo: 'รวมหลายใบ', partial: 'ตั้งไม่ครบ' };
    const period = fcFmtMonth(ym);

    /* ── ฝั่งธนาคาร: 1 รายการในบิล + ผลจับคู่ของมัน ── */
    const pairOf = {};
    recon.pairs.forEach(p => { pairOf[p.txn.id] = p; });
    const bank = txns.map(t => {
      const p = pairOf[t.id] || null;
      const ms = p ? (p.mangos || [p.mango]).filter(Boolean) : [];
      const ofAmt = p ? sumOf(ms, m => m.amount) : null;
      const diff = p ? r2(ofAmt - fcNum(t.amount)) : null;
      return {
        t, p, ms, ofAmt, diff,
        plate: plateKey(t.plateNo) || NA_PLATE, dept: deptKey(fcDeptOf(t)),
        desc: fcOfDesc(t), form: fcOfFormRow(t),
        status: p ? (Math.abs(diff) >= 0.01 ? 'ตั้งแล้ว (ยอดต่าง)' : 'ตั้งแล้ว')
          : t.status === 'ignored' ? 'ไม่ต้องตั้ง'
          : t.status === 'of_pending' ? 'ตั้งแล้ว รอขึ้นระบบ' : 'ยังไม่ตั้ง OF',
        ofNo: uniq(ms.map(m => fcTrim(m.docNo))).join(', '),
      };
    });
    const byDate = (a, b) => String(a.t.postDate || '').localeCompare(String(b.t.postDate || '')) ||
      String(a.t.trnDate || '').localeCompare(String(b.t.trnDate || '')) ||
      String(a.t.trnTime || '').localeCompare(String(b.t.trnTime || ''));
    const bankByDate = bank.slice().sort(byDate);                    // FORM BANK = ลำดับเดียวกับไฟล์ธนาคาร
    const bankByDesc = bank.slice().sort((a, b) => a.desc.localeCompare(b.desc, 'th') || byDate(a, b));
    const bankOf = {};
    bank.forEach(b => { bankOf[b.t.id] = b; });

    /* ── ฝั่ง OF: ที่นับในรอบนี้ / ที่ไม่นับ (เกณฑ์เดียวกับการ์ด "ตั้งเกิน" บนหน้าจอ) ── */
    const tailOf = (s) => (plateKey(s).match(/(\d{1,4})$/) || [])[1] || '';
    const plateByNorm = {}, plateByTail = {};
    bank.forEach(b => {
      plateByNorm[fcPlateNorm(b.plate)] = b.plate;
      const tl = tailOf(b.plate);
      if (tl) (plateByTail[tl] = plateByTail[tl] || {})[b.plate] = 1;
    });
    // ใบที่ไม่มีคู่: หาทะเบียนของบิลที่น่าจะเป็นคันเดียวกัน (VIP n → ทะเบียนเต็ม → เลขท้ายที่มีคันเดียว)
    const guessPlate = (m) => {
      const own = plateKey(m.plateNo);
      const vip = (fcTrim(m.itemName) + ' ' + fcTrim(m.docRemark)).match(/VIP\s*(\d{1,2})/i);
      if (vip && plateByNorm['VIP' + Number(vip[1])]) return plateByNorm['VIP' + Number(vip[1])];
      if (own && plateByNorm[fcPlateNorm(own)]) return plateByNorm[fcPlateNorm(own)];
      const tails = uniq([tailOf(own), fcTrim(m.plateTail)]);
      for (let i = 0; i < tails.length; i++) {
        const hit = Object.keys(plateByTail[tails[i]] || {});
        if (hit.length === 1) return hit[0];
      }
      return own || NA_PLATE;
    };
    const ofRows = [], ofSkip = [];
    // billDept = แผนกที่ใช้บัตรของบิลคู่มัน (ใบที่ไม่มีคู่ใช้ฝ่ายที่ตั้งใน Mango) — คีย์ที่ตารางรายทะเบียนใช้คู่กับทะเบียน
    recon.pairs.forEach(p => {
      const b = bankOf[p.txn.id];
      if (!b) return;
      b.ms.forEach(m => ofRows.push({ m, b, plate: b.plate, dept: deptKey(m.deptCode), billDept: b.dept,
        status: p.weak ? 'จับคู่แล้ว · ควรตรวจซ้ำ' : 'จับคู่แล้ว' }));
    });
    recon.mangoOnly.forEach(m => {
      const pay = fcPayState(m);
      const why = nextOf[m.id] ? 'รอตัดกับบิลรอบ ' + fcFmtMonth(nextOf[m.id])
        : pay === 'full' ? 'ตัดจ่ายแล้ว' + (fcTrim(m.pvNo) ? ' (' + fcTrim(m.pvNo) + ')' : '') + ' — กระทบจบไปกับบิลรอบก่อน'
        : pay === 'partial' ? 'PV จ่ายบางส่วน — ต้องเช็คเอง'
        : m.status === 'over_ok' ? 'ตรวจแล้ว ไม่ใช่ตั้งเกิน'
        : m.status === 'over_cancelled' ? 'Mango ตัดออกแล้ว' : '';
      if (why) ofSkip.push({ m, why, plate: plateKey(m.plateNo), dept: fcTrim(m.deptCode).toUpperCase() });
      else ofRows.push({ m, b: null, plate: guessPlate(m), dept: deptKey(m.deptCode), billDept: deptKey(m.deptCode),
        status: m.status === 'over_confirmed' ? 'ตั้งเกิน — รอ Mango ตัดออก' : 'ตั้งเกิน (ไม่มีในบิล)' });
    });
    const byDoc = (a, b) => String(a.m.docNo || '').localeCompare(String(b.m.docNo || '')) ||
      fcNum(a.m.itemNo) - fcNum(b.m.itemNo);
    ofRows.sort(byDoc); ofSkip.sort(byDoc);

    /* ── สรุปรายทะเบียน (แยกตามแผนกที่ใช้บัตร) / รายฝ่าย ──
     *   สองตารางใช้ "แผนกที่ใช้บัตร" ชุดเดียวกันฝั่ง BANK → รวมแถวรายทะเบียนของฝ่ายไหน ได้ยอด BANK ของฝ่ายนั้นพอดี
     *   ที่ยังต่างได้คือฝั่ง OF: รายทะเบียนนับตามบิลที่จับคู่ · รายฝ่ายนับตามฝ่ายที่ตั้งเบิกใน Mango
     *   → บิลของฝ่ายหนึ่งที่ไปตั้ง OF อีกฝ่าย เขียนบอกไว้ทั้งสองตาราง (ปัญหา / ที่มาของผลต่าง) */
    const byDeptKey = (a, b) => (a === NA_DEPT) - (b === NA_DEPT) || a.localeCompare(b);
    const plates = [], plateIdx = {};
    const plateG = (dept, plate) => {
      const k = dept + '\u0001' + plate;
      return plateIdx[k] || (plateIdx[k] = plates[plates.push({ key: plate, dept, driver: '',
        count: 0, bank: 0, of: 0, pend: [], wait: [], ign: [], diffs: [], overs: [], notes: [], ofAt: {} }) - 1]);
    };
    bankByDesc.forEach(b => {
      const g = plateG(b.dept, b.plate);
      g.count += 1; g.bank += fcNum(b.t.amount);
      if (!g.driver) g.driver = fcTrim(b.t.driverName);
      if (!b.p) (b.t.status === 'ignored' ? g.ign : b.t.status === 'of_pending' ? g.wait : g.pend).push(b);
      else if (Math.abs(b.diff) >= 0.01) g.diffs.push(b);
      if (fcTrim(b.t.note)) g.notes.push(fcTrim(b.t.note));
    });
    ofRows.forEach(o => {
      const g = plateG(o.billDept, o.plate);
      g.of += fcNum(o.m.amount);
      if (!o.b) g.overs.push(o);
      else if (o.dept !== o.billDept) g.ofAt[o.dept] = (g.ofAt[o.dept] || 0) + fcNum(o.m.amount);
      if (fcTrim(o.m.note)) g.notes.push(fcTrim(o.m.note));
    });
    plates.sort((a, b) => byDeptKey(a.dept, b.dept));            // sort คงลำดับเดิมในฝ่ายเดียวกัน (ชื่อผู้ถือบัตร → ทะเบียน)
    const nAmt = (list) => list.length + ' รายการ ' + fmtMoney(sumOf(list, b => b.t.amount));
    const listAmt = (map) => Object.keys(map).sort(byDeptKey).map(k => k + ' ' + fmtMoney(r2(map[k]))).join(', ');
    plates.forEach(g => {
      const issue = [];
      if (g.dept === NA_DEPT && g.count) issue.push('ยังไม่ระบุแผนก');
      if (Object.keys(g.ofAt).length) issue.push('OF ตั้งที่ฝ่าย ' + listAmt(g.ofAt));
      if (g.pend.length) issue.push('ยังไม่ตั้ง OF ' + nAmt(g.pend));
      if (g.ign.length) issue.push('ไม่ต้องตั้ง ' + nAmt(g.ign));
      g.diffs.forEach(b => issue.push((b.diff < 0 ? 'OF ตั้งขาด ' : 'OF ตั้งเกิน ') + fmtMoney(Math.abs(b.diff)) +
        (b.ofNo ? ' (' + b.ofNo + ')' : '')));
      g.overs.forEach(o => issue.push('ตั้งเกิน ' + [fcTrim(o.m.docNo), fmtMoney(fcNum(o.m.amount))].filter(Boolean).join(' ') +
        (o.m.status === 'over_confirmed' ? ' — รอ Mango ตัดออก' : '')));
      g.issue = issue.join(' · ');
      g.follow = (g.wait.length ? ['ตั้ง OF แล้ว รอขึ้นระบบ ' + nAmt(g.wait)] : []).concat(uniq(g.notes)).join(' · ');
    });
    const depts = {}, deptName = {};
    const deptG = (k) => depts[k] || (depts[k] = { key: k, name: '', count: 0, bank: 0, of: 0,
      out: {}, inc: {}, pend: [], ign: [], wait: [], over: 0 });
    bank.forEach(b => {
      const g = deptG(b.dept);
      g.count += 1; g.bank += fcNum(b.t.amount);
      if (!b.p) (b.t.status === 'ignored' ? g.ign : b.t.status === 'of_pending' ? g.wait : g.pend).push(b);
    });
    ofRows.concat(ofSkip.map(o => ({ m: o.m, dept: o.dept, skip: true }))).forEach(o => {
      if (o.dept && fcTrim(o.m.deptName) && !deptName[o.dept]) deptName[o.dept] = fcTrim(o.m.deptName);
      if (o.skip) return;
      const g = deptG(o.dept), v = fcNum(o.m.amount);
      g.of += v;
      if (!o.b) g.over += v;
      else if (o.dept !== o.billDept) {                       // บิลของ billDept แต่ไปตั้ง OF ที่ dept
        g.inc[o.billDept] = (g.inc[o.billDept] || 0) + v;
        const from = deptG(o.billDept);
        from.out[o.dept] = (from.out[o.dept] || 0) + v;
      }
    });
    const deptRows = Object.keys(depts).sort(byDeptKey).map(k => {
      const g = depts[k], note = [];
      if (k === NA_DEPT && g.count) note.push('บิลที่ยังไม่ระบุแผนก ' + g.count + ' รายการ');
      if (Object.keys(g.out).length) note.push('บิลฝ่ายนี้ไปตั้ง OF ที่ ' + listAmt(g.out));
      if (Object.keys(g.inc).length) note.push('ตั้ง OF ให้บิลของฝ่าย ' + listAmt(g.inc));
      if (g.pend.length) note.push('ยังไม่ตั้ง OF ' + nAmt(g.pend));
      if (g.wait.length) note.push('ตั้งแล้วรอขึ้นระบบ ' + nAmt(g.wait));
      if (g.ign.length) note.push('ไม่ต้องตั้ง ' + nAmt(g.ign));
      if (g.over) note.push('ตั้งเกิน ' + fmtMoney(r2(g.over)));
      return Object.assign(g, { name: deptName[k] || '', note: note.join(' · ') });
    });

    /* ── ชีต DATA (ฟอร์มตั้ง OF) ── */
    const formCols = FC_OF_FORM.map((label, i) => ({
      label, width: FC_OF_FORM_W[i], fold: FC_OF_FORM_W[i] == null,
      type: i === FC_OF_AMT ? 'number' : 'text', center: i === 0 || i === 5 || i === 12,
      get: (b) => b.form[i],
    })).concat([
      { label: 'ทะเบียน', calc: true, width: 14, get: b => b.plate },
      { label: 'ฝ่าย', calc: true, width: 9, center: true, get: b => b.dept },
      { label: 'ผู้ถือบัตร', calc: true, width: 24, get: b => b.t.driverName },
      { label: 'สถานะ', calc: true, width: 18, get: b => b.status },
      { label: 'เลขที่ OF', calc: true, width: 18, get: b => b.ofNo },
    ]);
    /* ── ชีต FORM BANK ── */
    const bankCols = [
      { label: 'POST_DATE', type: 'date', width: 12, get: b => b.t.postDate },
      { label: 'TRN_DATE', type: 'date', width: 12, get: b => b.t.trnDate },
      { label: 'TRN_TIME', center: true, width: 10, get: b => b.t.trnTime },
      { label: 'CUSTOMER_ID', width: 19, get: b => b.t.customerId },
      { label: 'DRIVER_NAME', width: 24, get: b => b.t.driverName },
      { label: 'CARD_NO', width: 19, get: b => b.t.cardNo },
      { label: 'PLATE_NO', width: 12, get: b => b.t.plateNo },
      { label: 'ฝ่าย', calc: true, center: true, width: 9, get: b => b.dept },
      { label: 'DEPARTMENT', width: 14, get: b => b.t.department },
      { label: 'COSTCENTER', width: 13, get: b => b.t.costCenter },
      { label: 'Description', calc: true, width: 60, get: b => b.desc },
      { label: 'MER_NO', width: 12, get: b => b.t.merchantNo },
      { label: 'TAX_ID', width: 16, get: b => b.t.merchantTaxId },
      { label: 'MERCHANT_NAME_THAI', width: 30, get: b => b.t.merchantName },
      { label: 'MERCHANT_LOCATION', width: 46, get: b => b.t.merchantLocation },
      { label: 'INVOICE_NO', width: 21, get: b => b.t.invoiceNo },
      { label: 'PRODUCT_NAME', width: 15, get: b => b.t.productName },
      { label: 'QTY_LITRE', type: 'qty', width: 11, get: b => b.t.qtyLitre },
      { label: 'EXCLUDE_VAT_AMOUNT', type: 'number', width: 13, get: b => b.t.exVatAmount },
      { label: 'VAT_AMOUNT', type: 'number', width: 11, get: b => b.t.vatAmount },
      { label: 'AMT_BAHT', type: 'number', width: 13, get: b => b.t.amount },
      { label: 'PRICE_RATE', type: 'number', width: 11, get: b => b.t.priceRate },
      { label: 'ODOMETER', type: 'int', width: 11, get: b => b.t.odometer },
      { label: 'DISTANCE', type: 'int', width: 10, get: b => b.t.distance },
      { label: 'KM_LITRE', type: 'number', width: 10, get: b => b.t.kmPerLitre },
      { label: 'BRANCH_NUMBER', center: true, width: 11, get: b => b.t.branchNo },
      { label: 'MERCHANT_NAME_VAT_CERTIFICATE_PP20', width: 32, get: b => b.t.pp20Name },
      { label: 'ADDRESS_VAT_CERTIFICATE_PP20', width: 48, get: b => b.t.pp20Address },
      { label: 'สถานะ', calc: true, width: 18, get: b => b.status },
      { label: 'เลขที่ OF', calc: true, width: 18, get: b => b.ofNo },
      { label: 'ยอดตาม OF', calc: true, type: 'number', width: 13, get: b => b.ofAmt },
      { label: 'ผลต่าง (OF − BANK)', calc: true, type: 'number', width: 13, get: b => b.diff },
      { label: 'เลข PV', calc: true, width: 17, get: b => uniq(b.ms.map(m => fcTrim(m.pvNo))).join(', ') },
      { label: 'วันที่จ่าย', calc: true, center: true, width: 12, get: b => uniq(b.ms.map(m => fcDMY(m.payDate, '/'))).join(', ') },
      { label: 'วิธีจับคู่', calc: true, width: 16, get: b => b.p ? (HOW[b.p.how] || 'อัตโนมัติ') + (b.p.weak && b.p.how !== 'partial' ? ' (ควรตรวจซ้ำ)' : '') : '' },
      { label: 'หมายเหตุ', calc: true, width: 28, get: b => b.t.note },
    ];
    /* ── ชีต OF-Mango / นอกรอบ — หัวคอลัมน์ใช้ชื่อเดียวกับรายงาน OF 9.3 ของ Mango ── */
    const mangoCols = [
      { label: 'expens_code', center: true, width: 11, get: o => o.m.expenseCode },
      { label: 'expens_name', width: 20, get: o => o.m.expenseName },
      { label: 'empcode', width: 12, get: o => o.m.empCode },
      { label: 'empfullname_t', width: 24, get: o => o.m.empName },
      { label: 'amt', type: 'number', width: 13, get: o => o.m.amount },
      { label: 'itemname', width: 70, get: o => o.m.itemName },
      { label: 'ทะเบียน', calc: true, width: 14, get: o => o.plate },
      { label: 'remark', width: 40, get: o => o.m.vendor },
      { label: 'docno', width: 15, get: o => o.m.docNo },
      { label: 'refdate_s', center: true, width: 12, get: o => fcDMY(o.m.refDate, '/') },
      { label: 'vchno', width: 16, get: o => o.m.vchNo },
      { label: 'vchdate_s', center: true, width: 12, get: o => fcDMY(o.m.vchDate, '/') },
      { label: 'dpt_code', center: true, width: 10, get: o => o.dept },
      { label: 'dpt_name', width: 30, get: o => o.m.deptName },
      { label: 'itemno', center: true, width: 8, get: o => o.m.itemNo },
      { label: 'plno', width: 17, get: o => o.m.pvNo },
      { label: 'paydate_s', center: true, width: 12, get: o => fcDMY(o.m.payDate, '/') },
      { label: 'รอบ OF', calc: true, center: true, width: 11, get: o => o.m.ym ? fcFmtMonth(o.m.ym) : '' },
    ];
    const ofCols = mangoCols.concat([
      { label: 'สถานะ', calc: true, width: 24, get: o => o.status },
      { label: 'บิล: ฝ่าย', calc: true, center: true, width: 10, get: o => o.billDept },
      { label: 'บิล: วันที่เติม', calc: true, type: 'date', width: 12, get: o => o.b ? o.b.t.trnDate : '' },
      { label: 'บิล: INVOICE_NO', calc: true, width: 21, get: o => o.b ? o.b.t.invoiceNo : '' },
      { label: 'บิล: AMT_BAHT', calc: true, type: 'number', width: 13, get: o => o.b ? o.b.t.amount : '' },
      { label: 'หมายเหตุ', calc: true, width: 28, get: o => o.m.note },
    ]);
    const skipCols = mangoCols.concat([
      { label: 'เหตุผลที่ไม่นับในรอบนี้', calc: true, width: 46, get: o => o.why },
      { label: 'หมายเหตุ', calc: true, width: 28, get: o => o.m.note },
    ]);
    /* ── ชีต Dep. — คอลัมน์ A ชื่อ · B รหัสฝ่าย · D ทะเบียน ตามที่ fcParseDepSheet อ่าน ── */
    const holders = [], holderIdx = {};
    bankByDesc.forEach(b => {
      const name = fcTrim(b.t.driverName) || b.plate;
      const k = name + '|' + b.plate;
      const h = holderIdx[k] || (holderIdx[k] = holders[holders.push({ name, plate: b.plate, dept: fcDeptAuto(b.t).toUpperCase(),
        cardNo: fcTrim(b.t.cardNo), count: 0, amount: 0 }) - 1]);
      h.count += 1; h.amount += fcNum(b.t.amount);
    });
    const depCols = [
      { label: 'Name', width: 28, get: h => h.name },
      { label: 'Dept Code', center: true, width: 11, get: h => h.dept },
      { label: 'ชื่อฝ่าย', width: 34, get: h => deptName[h.dept] || '' },
      { label: 'ทะเบียน', width: 14, get: h => h.plate === NA_PLATE ? '' : h.plate },
      { label: 'เลขบัตร', width: 20, get: h => h.cardNo },
      { label: 'จำนวนครั้ง', type: 'int', width: 11, get: h => h.count },
      { label: 'ยอดเงิน (บาท)', type: 'number', width: 15, get: h => r2(h.amount) },
    ];

    const colOf = (cols, label) => XLSX.utils.encode_col(cols.findIndex(c => c.label === label));
    const bankAmt = sumOf(bank, b => b.t.amount), ofAmt = sumOf(ofRows, o => o.m.amount);
    const wb = XLSX.utils.book_new();
    XLSX.utils.book_append_sheet(wb, fcDataSummarySheet({
      title: 'สรุปค่าน้ำมัน FLEET CARD · รอบ ' + period + '   —   ยอดเรียกเก็บของธนาคาร (BANK) เทียบกับที่ตั้ง OF ใน Mango',
      note: 'ออกจากระบบ Water POG Financial Console เมื่อ ' + new Date().toLocaleString('th-TH'),
      countText: 'บิลธนาคาร ' + bank.length + ' รายการ  ·  OF ' + ofRows.length + ' บรรทัด (' +
        uniq(ofRows.map(o => fcTrim(o.m.docNo))).length + ' ใบ)',
      skipText: ofSkip.length ? 'มีบรรทัด OF ของรอบนี้ที่ไม่นับ ' + ofSkip.length + ' บรรทัด (' + fmtMoney(sumOf(ofSkip, o => o.m.amount)) +
        ' บาท) เพราะไม่ใช่งานของบิลรอบนี้ — ดูเหตุผลรายใบที่ชีต "นอกรอบ (ไม่นับ)"' : '',
      bankAmt, ofAmt, depts: deptRows, plates,
      col: { amt: XLSX.utils.encode_col(FC_OF_AMT), plate: colOf(formCols, 'ทะเบียน'), dept: colOf(formCols, 'ฝ่าย'),
        ofAmt: colOf(ofCols, 'amt'), ofPlate: colOf(ofCols, 'ทะเบียน'), ofDept: colOf(ofCols, 'dpt_code'),
        ofBillDept: colOf(ofCols, 'บิล: ฝ่าย') },
    }), 'สรุป');
    XLSX.utils.book_append_sheet(wb, fcXlRawSheet(formCols, bankByDesc), 'DATA');
    XLSX.utils.book_append_sheet(wb, fcXlRawSheet(bankCols, bankByDate), 'FORM BANK');
    XLSX.utils.book_append_sheet(wb, fcXlRawSheet(ofCols, ofRows), 'OF-Mango');
    if (ofSkip.length) XLSX.utils.book_append_sheet(wb, fcXlRawSheet(skipCols, ofSkip), 'นอกรอบ (ไม่นับ)');
    XLSX.utils.book_append_sheet(wb, fcXlRawSheet(depCols, holders), 'Dep.');

    const mm = String(ym || '').split('-');
    fcXlsxDownload(wb, 'DATA ด.' + (mm[1] || '') + '.' + (mm[0] || '') + '.xlsx', {
      'สรุป': { tab: FC_XL.head },
      'DATA': { freeze: 1, tab: FC_XL.calc },
      'FORM BANK': { freeze: 1 },
      'OF-Mango': { freeze: 1, tab: FC_XL.user },
      'นอกรอบ (ไม่นับ)': { freeze: 1 },
      'Dep.': { freeze: 1 },
    });
    try {
      if (window.WTPData && WTPData.logEvent) {
        WTPData.logEvent('export', '', 'ส่งออกไฟล์ DATA Fleet Card รอบ ' + period + ' (บิล ' + bank.length + ' · OF ' + ofRows.length + ' แถว)');
      }
    } catch (_) {}
  }

  /* ── แท็บ 2: รายการเติมน้ำมันทั้งหมด ─────────────────────────────── */
  function FcTxnsTab({ rows: allRows, q, setQ, ym, program, recon, otherRows = [] }) {
    const isExecProg = program === 'exec';
    const [sort, setSort] = useState('name');       // name | date | amount
    const matchedId = {};
    recon.pairs.forEach(p => { matchedId[p.txn.id] = p; });
    // เลขใบ OF ที่รายการนี้ถูกตั้งไว้ (มีได้หลายใบ)
    const ofNoOf = (t) => {
      const p = matchedId[t.id];
      if (!p) return '';
      return (p.mangos || [p.mango]).filter(Boolean).map(m => m.docNo).filter(Boolean).join(', ');
    };
    const nameOf = (t) => isExecProg ? (t.driverName || t.merchantName || '') : (t.plateNo || t.driverName || '');
    const rows = useMemo(() => allRows.slice().sort((a, b) =>
      sort === 'amount' ? fcNum(b.amount) - fcNum(a.amount)
      : sort === 'date' ? String(a.trnDate).localeCompare(String(b.trnDate))
      : (String(nameOf(a)).localeCompare(String(nameOf(b)), 'th') ||
         String(a.trnDate).localeCompare(String(b.trnDate)))), [allRows, sort, isExecProg]);
    const total = rows.reduce((s, r) => s + fcNum(r.amount), 0);
    const litre = rows.reduce((s, r) => s + fcNum(r.qtyLitre), 0);

    return (
      <div className="card" style={{ overflow: 'hidden' }}>
        <div style={{ padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', borderBottom: '1px solid var(--line)' }}>
          <input className="input" value={q} onChange={e => setQ(e.target.value)} style={{ width: 320, fontSize: 12.5 }}
            placeholder={isExecProg ? 'ค้นหา ร้านค้า / ผู้ถือบัตร' : 'ค้นหา ทะเบียน / ผู้ถือบัตร / ปั๊ม / เลขใบกำกับ'} />
          <select className="select input" value={sort} onChange={e => setSort(e.target.value)}
            style={{ fontSize: 11.5, padding: '4px 8px' }} title="ลำดับการแสดงผล">
            <option value="name">{isExecProg ? 'เรียงตามผู้ถือบัตร' : 'เรียงตามทะเบียน'}</option>
            <option value="date">เรียงตามวันที่</option>
            <option value="amount">เรียงตามยอด มาก→น้อย</option>
          </select>
          <div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--ink-600)' }}>
            {rows.length} รายการ{isExecProg ? '' : ' · ' + fmtNum(litre, 2) + ' ลิตร'} · <b>{fmtMoney(total)} บาท</b>
          </div>
          <button className="btn btn-ghost" onClick={() => fcExportTxnsExcel(rows, program, ym, matchedId)}
            title={'ส่งออก Excel — ' + rows.length + ' รายการ พร้อมชีตสรุป' + (isExecProg ? 'รายผู้ถือบัตร/ร้านค้า' : 'รายทะเบียน/แผนก')}>
            <Icon name="download" size={14} /> Excel + สรุป
          </button>
        </div>
        <div style={{ maxHeight: '62vh', overflow: 'auto' }}>
          <table style={{ width: '100%', borderCollapse: 'collapse' }}>
            <thead><tr>
              <th style={fcTh('left', 86)}>{isExecProg ? 'วันที่รูด' : 'วันที่'}</th>
              {isExecProg
                ? <th style={fcTh('left', 86)}>ลงบัญชี</th>
                : <th style={fcTh('left', 92)}>ทะเบียน</th>}
              <th style={fcTh('left')}>{isExecProg ? 'ร้านค้า' : 'ผู้ถือบัตร'}</th>
              {isExecProg ? null : <th style={fcTh('left', 52)}>แผนก</th>}
              <th style={fcTh('left')}>{isExecProg ? 'ผู้ถือบัตร' : 'ปั๊ม'}</th>
              {isExecProg ? null : <>
                <th style={fcTh('left', 88)}>ชนิด</th>
                <th style={fcTh('right', 62)}>ลิตร</th>
                <th style={fcTh('right', 62)}>บาท/ล.</th>
              </>}
              <th style={fcTh('right', 86)}>ยอดเงิน</th>
              <th style={fcTh('left', 108)}>ใบ OF</th>
              <th style={fcTh('center', 74)}>สถานะ</th>
            </tr></thead>
            <tbody>
              {rows.length === 0 && (
                <tr><td colSpan={isExecProg ? 7 : 11} style={{ textAlign: 'center', color: 'var(--ink-400)', padding: 18, fontSize: 12 }}>ไม่พบรายการ</td></tr>
              )}
              {rows.map(t => (
                <tr key={t.id} style={{ background: t.status === 'ignored' ? 'var(--ink-50)' : undefined }}>
                  <td style={fcTd()}>
                    {t.trnDate}
                    {!isExecProg && t.trnTime ? <span style={{ color: 'var(--ink-400)' }}>{' · ' + String(t.trnTime).slice(0, 5)}</span> : null}
                  </td>
                  {isExecProg
                    ? <td style={fcTd()}>{t.postDate || '—'}</td>
                    : <td style={fcTd(null, { fontWeight: 600 })} title={'บัตร ' + (t.cardNo || '—')}>
                        {t.plateNo || '—'}
                        <span style={{ color: 'var(--ink-400)', fontWeight: 400 }}>{' ···' + String(t.cardNo || '').slice(-4)}</span>
                      </td>}
                  <td style={fcTd(null, { maxWidth: isExecProg ? 300 : 150 })} title={isExecProg ? t.merchantName : t.driverName}>
                    {(isExecProg ? t.merchantName : t.driverName) || '—'}
                  </td>
                  {isExecProg ? null : <td style={fcTd()}>{fcDeptOf(t) || '—'}</td>}
                  <td style={fcTd(null, { maxWidth: isExecProg ? 190 : 230 })}
                    title={isExecProg ? t.driverName : (t.pp20Name || t.merchantName)}>
                    {(isExecProg ? t.driverName : (t.pp20Name || t.merchantName)) || '—'}
                  </td>
                  {isExecProg ? null : <>
                    <td style={fcTd(null, { color: 'var(--ink-600)' })}>{t.productName || '—'}</td>
                    <td style={fcTd('right')}>{fmtNum(t.qtyLitre, 2)}</td>
                    <td style={fcTd('right', { color: 'var(--ink-500)' })}>{fmtNum(t.priceRate, 2)}</td>
                  </>}
                  <td style={fcTd('right', { fontWeight: 700 })}>{fmtMoney(t.amount)}</td>
                  <td style={fcTd(null, { fontSize: 11, color: 'var(--brand-700)' })} title={ofNoOf(t) || 'ยังไม่ได้ตั้ง OF'}>
                    {ofNoOf(t) || <span style={{ color: 'var(--ink-300)' }}>—</span>}
                  </td>
                  <td style={fcTd('center')}>
                    {t.status === 'ignored'
                      ? <FcTag kind="mute">ไม่ตั้ง</FcTag>
                      : (matchedId[t.id] ? <FcTag kind="ok">ตั้งแล้ว</FcTag> : <FcTag kind="warn">ค้าง</FcTag>)}
                  </td>
                </tr>
              ))}
            </tbody>
            <tfoot><tr style={{ position: 'sticky', bottom: 0, background: 'var(--brand-50)' }}>
              <td colSpan={isExecProg ? 4 : 6} style={{ padding: '6px 8px', fontSize: 11.5, fontWeight: 700, borderTop: '1px solid var(--line)' }}>รวม</td>
              {isExecProg ? null : (
                <><td style={{ padding: '6px 8px', fontSize: 11.5, fontWeight: 700, textAlign: 'right', fontVariantNumeric: 'tabular-nums', borderTop: '1px solid var(--line)' }}>{fmtNum(litre, 2)}</td>
                  <td style={{ borderTop: '1px solid var(--line)' }}></td></>
              )}
              <td style={{ padding: '6px 8px', fontSize: 12, fontWeight: 800, textAlign: 'right', fontVariantNumeric: 'tabular-nums', borderTop: '1px solid var(--line)' }}>{fmtMoney(total)}</td>
              <td style={{ borderTop: '1px solid var(--line)' }}></td>
              <td style={{ borderTop: '1px solid var(--line)' }}></td>
            </tr></tfoot>
          </table>
        </div>

        {/* บัตรเครดิต: แถวที่ไม่ใช่ค่าใช้จ่ายรอบนี้ — โชว์ไว้ให้ครบตาม statement แต่ไม่นับในยอดกระทบ */}
        {isExecProg && otherRows.length > 0 && (
          <div style={{ borderTop: '1px solid var(--line)' }}>
            <div style={{ padding: '8px 14px', background: 'var(--ink-50)', fontSize: 12, fontWeight: 700, color: 'var(--ink-700)' }}>
              รายการอื่นใน statement (ไม่ต้องตั้ง OF) — {otherRows.length} แถว
            </div>
            <table className="tbl" style={{ width: '100%', fontSize: 12 }}>
              <tbody>
                {otherRows.map(t => (
                  <tr key={t.id}>
                    <td style={{ whiteSpace: 'nowrap', width: 110 }}>{t.trnDate || '—'}</td>
                    <td>{t.merchantName}</td>
                    <td style={{ width: 130 }}>
                      <FcTag kind="mute">
                        {t.txnType === 'balance' ? 'ยอดยกมา' : t.txnType === 'payment' ? 'ตัดชำระบัตร' : 'เงินคืน/โอนเข้า'}
                      </FcTag>
                    </td>
                    <td className="num" style={{ width: 130, color: fcNum(t.amount) < 0 ? 'var(--good)' : 'var(--ink-600)' }}>{fmtMoney(t.amount)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </div>
    );
  }

  /* ── แท็บ 3: สรุป ────────────────────────────────────────────────── */
  function FcSummaryTab({ rows, allTxns, ym, program, yearRows = [] }) {
    const isExecProg = program === 'exec';
    const byCard = useMemo(() => fcGroup(rows, r => (r.plateNo || r.cardNo), r => (r.plateNo || ('···' + String(r.cardNo || '').slice(-4)))), [rows]);
    const byDept = useMemo(() => fcGroup(rows, r => (fcDeptOf(r) || 'ไม่ระบุแผนก')), [rows]);
    const byProduct = useMemo(() => fcGroup(rows, r => (r.productName || '—')), [rows]);
    const byMerchant = useMemo(() => fcGroup(rows, r => fcTxtNorm(r.pp20Name || r.merchantName), r => (r.pp20Name || r.merchantName || '—')), [rows]);
    const byHolder = useMemo(() => fcGroup(rows, r => (r.driverName || r.cardNo || '—')), [rows]);
    const driverOf = useMemo(() => {
      const m = {};
      rows.forEach(r => { const k = r.plateNo || r.cardNo; if (r.driverName && !m[k]) m[k] = r.driverName; });
      return m;
    }, [rows]);
    // เทรนด์ 12 รอบล่าสุด (ทุกเดือนที่มีข้อมูล)
    const trend = useMemo(() => {
      const map = {};
      allTxns.forEach(r => {
        if (!r.ym) return;
        if (r.txnType && r.txnType !== 'expense') return;
        if (!map[r.ym]) map[r.ym] = { ym: r.ym, amount: 0, litre: 0, count: 0 };
        map[r.ym].amount += fcNum(r.amount);
        map[r.ym].litre += fcNum(r.qtyLitre);
        map[r.ym].count += 1;
      });
      return Object.values(map).sort((a, b) => String(a.ym).localeCompare(String(b.ym))).slice(-12);
    }, [allTxns]);
    const maxTrend = Math.max(1, ...trend.map(t => t.amount));
    const total = rows.reduce((s, r) => s + fcNum(r.amount), 0);

    const Table = ({ title, data, showDriver }) => {
      const top = data.length ? Math.max.apply(null, data.map(g => g.amount)) : 0;
      return (
        <div className="card" style={{ overflow: 'hidden' }}>
          <div style={{ padding: '8px 12px', fontWeight: 700, fontSize: 12.5, borderBottom: '1px solid var(--line)',
            display: 'flex', alignItems: 'baseline', gap: 8 }}>
            <span>{title}</span>
            <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--ink-500)', fontWeight: 600 }}>
              {fmtMoney(data.reduce((s, g) => s + g.amount, 0))}
            </span>
          </div>
          <div style={{ maxHeight: 300, overflow: 'auto' }}>
            <table style={{ width: '100%', borderCollapse: 'collapse' }}>
              <thead><tr>
                <th style={fcTh('left')}>รายการ</th>
                <th style={fcTh('right', 46)}>ครั้ง</th>
                {isExecProg ? null : <th style={fcTh('right', 66)}>ลิตร</th>}
                <th style={fcTh('right', 84)}>ยอดเงิน</th>
                <th style={fcTh('right', 76)}>สัดส่วน</th>
              </tr></thead>
              <tbody>
                {data.map(g => (
                  <tr key={g.key}>
                    <td style={fcTd(null, { maxWidth: 200 })} title={g.label + (showDriver && driverOf[g.key] ? ' · ' + driverOf[g.key] : '')}>
                      {g.label}
                      {showDriver && driverOf[g.key]
                        ? <span style={{ fontSize: 10.5, color: 'var(--ink-400)' }}>{' · ' + driverOf[g.key]}</span>
                        : null}
                    </td>
                    <td style={fcTd('right', { color: 'var(--ink-500)' })}>{g.count}</td>
                    {isExecProg ? null : <td style={fcTd('right')}>{fmtNum(g.litre, 2)}</td>}
                    <td style={fcTd('right', { fontWeight: 700 })}>{fmtMoney(g.amount)}</td>
                    <td style={fcTd('right', { color: 'var(--ink-500)' })}>
                      {/* แถบสัดส่วนเล็กๆ อ่านง่ายกว่าตัวเลข % เปล่าๆ */}
                      <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, justifyContent: 'flex-end', width: '100%' }}>
                        <span style={{ width: 34, height: 4, borderRadius: 3, background: 'var(--ink-100)', overflow: 'hidden', flex: 'none' }}>
                          <span style={{ display: 'block', height: '100%', width: (top ? Math.round(g.amount / top * 100) : 0) + '%', background: 'var(--brand-500)' }} />
                        </span>
                        {total ? fmtNum(g.amount / total * 100, 1) : '0.0'}%
                      </span>
                    </td>
                  </tr>
                ))}
                {data.length === 0 && <tr><td colSpan={isExecProg ? 4 : 5} style={{ textAlign: 'center', color: 'var(--ink-400)', padding: 14, fontSize: 11.5 }}>ไม่มีข้อมูล</td></tr>}
              </tbody>
            </table>
          </div>
        </div>
      );
    };

    return (
      <>
        {/* ── ยอดสะสมทั้งปี — ข้ามรอบไปมาแล้วต้องไม่มีอะไรตกหล่น ── */}
        {yearRows.length > 0 && (
          <div className="card" style={{ marginBottom: 16, overflow: 'hidden' }}>
            <div style={{ padding: '10px 14px', fontWeight: 700, fontSize: 13, borderBottom: '1px solid var(--line)' }}>
              ยอดสะสมปี {Number(String(ym).slice(0, 4)) + 543} — เทียบบิลธนาคารกับที่ตั้ง OF ทุกรอบ
            </div>
            <table style={{ width: '100%', borderCollapse: 'collapse' }}>
              <thead><tr>
                <th style={fcTh('left', 110)}>รอบ</th>
                <th style={fcTh('right', 110)}>ยอดบิล</th>
                <th style={fcTh('right', 66)}>รายการ</th>
                <th style={fcTh('right', 120)}>ตั้ง OF ได้แล้ว</th>
                <th style={fcTh('right', 100)}>ยังขาด</th>
                <th style={fcTh('center', 88)}>ครบไหม</th>
              </tr></thead>
              <tbody>
                {yearRows.map(r => (
                  <tr key={r.ym} style={r.ym === ym ? { background: 'var(--brand-50)' } : null}>
                    <td style={fcTd(null, { fontWeight: r.ym === ym ? 700 : 500 })}>{fcFmtMonth(r.ym)}</td>
                    <td style={fcTd('right')}>{fmtMoney(r.bankAmt)}</td>
                    <td style={fcTd('right', { color: 'var(--ink-500)' })}>{r.bankCount}</td>
                    <td style={fcTd('right', { color: 'var(--good)' })}>{fmtMoney(r.matchedAmt)}</td>
                    <td style={fcTd('right', { color: r.pendingCount ? 'var(--warn)' : 'var(--ink-400)' })}>
                      {r.pendingCount ? fmtMoney(r.pendingAmt) : '—'}
                    </td>
                    <td style={fcTd('center')}>
                      {r.pendingCount ? <FcTag kind="warn">ค้าง {r.pendingCount}</FcTag> : <FcTag kind="ok">ครบ</FcTag>}
                    </td>
                  </tr>
                ))}
              </tbody>
              <tfoot><tr>
                <td>รวมทั้งปี</td>
                <td className="num">{fmtMoney(yearRows.reduce((s, r) => s + r.bankAmt, 0))}</td>
                <td className="num">{yearRows.reduce((s, r) => s + r.bankCount, 0)}</td>
                <td className="num">{fmtMoney(yearRows.reduce((s, r) => s + r.matchedAmt, 0))}</td>
                <td className="num">{fmtMoney(yearRows.reduce((s, r) => s + r.pendingAmt, 0))}</td>
                <td style={{ textAlign: 'center' }}>
                  {yearRows.reduce((s, r) => s + r.pendingCount, 0) === 0
                    ? <FcTag kind="ok">ครบทั้งปี</FcTag>
                    : <FcTag kind="warn">ค้าง {yearRows.reduce((s, r) => s + r.pendingCount, 0)}</FcTag>}
                </td>
              </tr></tfoot>
            </table>
          </div>
        )}

        {trend.length > 1 && (
          <div className="card" style={{ padding: '14px 16px', marginBottom: 16 }}>
            <div style={{ fontWeight: 700, fontSize: 13, marginBottom: 12 }}>{isExecProg ? 'แนวโน้มยอดใช้จ่ายรายเดือน (จากรอบที่นำเข้าแล้ว)' : 'แนวโน้มค่าน้ำมันรายเดือน (จากรอบที่นำเข้าแล้ว)'}</div>
            <div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, height: 150 }}>
              {trend.map(t => (
                <div key={t.ym} style={{ flex: 1, textAlign: 'center' }} title={t.count + ' รายการ · ' + fmtNum(t.litre, 2) + ' ลิตร'}>
                  <div style={{ fontSize: 10.5, color: 'var(--ink-600)', marginBottom: 3, fontWeight: 600 }}>{fmtNum(t.amount / 1000, 1)}K</div>
                  <div style={{ height: Math.max(4, Math.round(t.amount / maxTrend * 100)), borderRadius: '5px 5px 0 0',
                    background: t.ym === ym ? 'linear-gradient(180deg, var(--brand-400), var(--brand-600))' : 'var(--ink-200)' }} />
                  <div style={{ fontSize: 10, color: 'var(--ink-500)', marginTop: 4 }}>{fcFmtMonth(t.ym)}</div>
                </div>
              ))}
            </div>
          </div>
        )}
        {isExecProg ? (
          <div className="grid grid-2">
            <Table title={'ร้านค้า (' + byMerchant.length + ')'} data={byMerchant} />
            <Table title={'ผู้ถือบัตร (' + byHolder.length + ')'} data={byHolder} />
          </div>
        ) : (
          <>
            <div className="grid grid-2" style={{ marginBottom: 16 }}>
              <Table title={'รายคัน / รายบัตร (' + byCard.length + ')'} data={byCard} showDriver />
              <Table title={'รายแผนก (' + byDept.length + ')'} data={byDept} />
            </div>
            <div className="grid grid-2">
              <Table title={'ชนิดน้ำมัน (' + byProduct.length + ')'} data={byProduct} />
              <Table title={'ปั๊ม / ผู้ขาย (' + byMerchant.length + ')'} data={byMerchant} />
            </div>
          </>
        )}
      </>
    );
  }

  window.FleetCardPage = FleetCardPage;
})();
