// ═══════════════════════════════════════════════════════════════════════════
// ONE-PAGE ผู้บริหาร — Investor Dashboard แท็บที่ 12
// รายงานสรุปการเงินและมูลค่าโครงการ 1 หน้า (เลย์เอาต์ตายตัว ไม่ต้องทำพรีเซนต์ใหม่ทุกเดือน)
//
// โฟลว: ตั้งงวด → ระบบเติมเอง → โหลดเทมเพลต → ฝ่ายขาย/เซอร์วิสกรอกใน Excel
//        → อัปกลับ + พรีวิว → ปิดงวด (แช่ตัวเลข) → พิมพ์ 1 หน้า
//
// เก็บที่ตาราง onePager (blob) · 1 แถว = 1 งวด (id = 'YYYY-MM')
// ★ WTPData.writeTable ลบ id ที่ไม่ได้ส่งไปด้วย → ต้องส่ง "ทุกงวด" ทุกครั้งที่บันทึก
// ทุก identifier ขึ้นต้น OP*/op* กัน global collision (in-browser Babel)
// ═══════════════════════════════════════════════════════════════════════════
(function () {
  const R = window.React;
  const el = R.createElement;
  const opSt = R.useState, opMemo = R.useMemo, opEff = R.useEffect;

  const OP_TABLE = 'onePager';
  const OPSRC_LBL = { eng: 'ฝ่ายวิศวะ', service: 'ฝ่ายเซอร์วิส', sales: 'ฝ่ายขาย' };
  const OP_TPL_VERSION = 1;

  // ── รายการที่ยอมรับ (ใช้แนะนำ + จับคู่ตอนอ่านไฟล์ · ไม่บล็อกค่าที่ไม่รู้จัก) ──
  const OP_COMPANIES = ['ENC', 'TWC', 'AXIA', 'INS', 'PP'];
  const OP_TYPES = ['PIIS', 'STIIS', 'PDP', 'PII', 'PL', 'STII', 'PDH', 'PD'];
  const OP_BUDGET_SOURCES = ['BBL', 'SOLA', 'อปท.', 'งบกลาง', 'เหลือจ่าย'];

  const OP_TH_MON = ['มกราคม', 'กุมภาพันธ์', 'มีนาคม', 'เมษายน', 'พฤษภาคม', 'มิถุนายน',
                     'กรกฎาคม', 'สิงหาคม', 'กันยายน', 'ตุลาคม', 'พฤศจิกายน', 'ธันวาคม'];

  /* ══════════════════════════════════════════════════════════════════════
     helpers
     ══════════════════════════════════════════════════════════════════════ */
  function opEsc(s) {
    return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  }
  function opNum(v) {
    if (v == null || v === '') return null;
    const n = Number(String(v).replace(/[,\s฿]/g, ''));
    return isNaN(n) ? null : n;
  }
  function opN0(n) { return (n == null || isNaN(n)) ? '—' : Math.round(n).toLocaleString('en-US'); }
  function opM(n) { return (n == null || isNaN(n)) ? '—' : '฿' + (Math.round(n * 10) / 10).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + 'M'; }
  function opB(n) { return (n == null || isNaN(n)) ? '—' : '฿' + (n / 1000).toFixed(2) + 'B'; }
  function opPct(n) { return (n == null || isNaN(n)) ? '—' : n.toFixed(2) + '%'; }
  function opBahtToM(b) { return (b == null || isNaN(b)) ? null : b / 1e6; }
  function opSum(arr, k) { return (arr || []).reduce((s, r) => s + (opNum(r[k]) || 0), 0); }

  function opTodayISO() {
    const d = new Date();
    const p = n => (n < 10 ? '0' : '') + n;
    return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
  }
  function opEndOfMonth(pid) {              // 'YYYY-MM' → 'YYYY-MM-DD' (วันสุดท้ายของเดือน)
    const [y, m] = String(pid || '').split('-').map(Number);
    if (!y || !m) return opTodayISO();
    const d = new Date(y, m, 0);
    const p = n => (n < 10 ? '0' : '') + n;
    return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
  }
  function opPeriodLabel(pid) {
    const [y, m] = String(pid || '').split('-').map(Number);
    if (!y || !m) return String(pid || '');
    return OP_TH_MON[m - 1] + ' ' + y;
  }
  function opThaiDate(iso) {
    if (!iso) return '—';
    const [y, m, d] = String(iso).split('-').map(Number);
    if (!y) return String(iso);
    return d + ' ' + OP_TH_MON[(m || 1) - 1] + ' ' + y;
  }
  function opWhen(ts) {
    try {
      const d = new Date(ts); const p = n => (n < 10 ? '0' : '') + n;
      return p(d.getDate()) + '/' + p(d.getMonth() + 1) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
    } catch (e) { return ''; }
  }
  function opSession() {
    try { return JSON.parse(localStorage.getItem('wtp-session') || 'null') || {}; } catch (e) { return {}; }
  }
  function opUser() { const s = opSession(); return s.displayName || s.username || ''; }
  function opCanEdit() {
    const r = String(opSession().role || '').toLowerCase();
    return r === 'staff' || r === 'manager' || r === 'admin';
  }
  function opCanFinalize() {
    const r = String(opSession().role || '').toLowerCase();
    return r === 'manager' || r === 'admin';
  }

  /* ══════════════════════════════════════════════════════════════════════
     โครงงวด + ตัวเลขที่ระบบดึงเอง
     ══════════════════════════════════════════════════════════════════════ */
  function opEmptyPeriod(pid) {
    return {
      id: pid,
      asOf: opEndOfMonth(pid),
      fyList: [],
      status: 'draft',
      sales: {
        signedByCompany: [], backlogByCompany: [], backlogByType: [], budget: [],
        contractWorkValueM: null, deliveredPct: null, totalCostM: null,
        warrantyExpired: null, signedDone: null, signedPending: null, signedPendingNote: '',
      },
      service: { installedTotal: null, provinces: null, cumulativeValueM: null, active: null, inactive: null, inSystem: null, warrantyExpired: null },
      // ตัวเลขที่อ่านจาก "ไฟล์จริงของวิศวะ" — ถ้ามี จะถูกใช้แทนค่าที่กรอกมือ
      eng: { signedByCompany: [], contractM: null, costM: null, grossM: null, receivedM: null, deliveredM: null, years: [] },
      // ยอดสะสมทุกปีงบ (จากไฟล์ Project Control ทุกปี) -> บล็อก 1
      cumulative: { years: {}, n: null, valueM: null },
      uploads: { sales: null, service: null, eng: null, cumulative: null },
      frozen: null,
      meta: { createdBy: opUser(), createdAt: new Date().toISOString(), finalizedBy: '', finalizedAt: null },
    };
  }

  // เติมช่องที่ขาดให้แถวที่โหลดมาจาก DB (กันงวดเก่าที่ยังไม่มีคีย์ใหม่)
  function opNormalize(row) {
    const base = opEmptyPeriod(row && row.id);
    const out = Object.assign({}, base, row || {});
    out.sales = Object.assign({}, base.sales, (row && row.sales) || {});
    out.service = Object.assign({}, base.service, (row && row.service) || {});
    out.eng = Object.assign({}, base.eng, (row && row.eng) || {});
    out.cumulative = Object.assign({}, base.cumulative, (row && row.cumulative) || {});
    if (!out.cumulative.years || typeof out.cumulative.years !== 'object') out.cumulative.years = {};
    if (!Array.isArray(out.eng.signedByCompany)) out.eng.signedByCompany = [];
    out.uploads = Object.assign({}, base.uploads, (row && row.uploads) || {});
    out.meta = Object.assign({}, base.meta, (row && row.meta) || {});
    ['signedByCompany', 'backlogByCompany', 'backlogByType', 'budget'].forEach(k => {
      if (!Array.isArray(out.sales[k])) out.sales[k] = [];
    });
    if (!Array.isArray(out.fyList)) out.fyList = [];
    return out;
  }

  // ตัวเลขฝั่งระบบ — คำนวณสดจากทะเบียนโครงการ (PCU.deriveProjects ผ่าน m ของหน้า investor)
  function opAuto(m, fyList) {
    const all = (m && m.active) || [];
    const scoped = (fyList && fyList.length) ? all.filter(r => fyList.indexOf(r.fy) >= 0) : all;
    let contract = 0, received = 0, ar = 0, signed = 0;
    scoped.forEach(r => {
      contract += r.contractAmt || 0;
      received += r.received || 0;
      ar += r.outstandingAR || 0;
      const sg = r._raw ? r._raw['เซ็นสัญญา'] : '';
      if (String(sg == null ? '' : sg).trim()) signed++;
    });
    const fyAvailable = [...new Set(all.map(r => r.fy).filter(Boolean))].sort((a, b) => a - b);
    return {
      projectsInSystem: all.length,
      scopedCount: scoped.length,
      contractM: opBahtToM(contract), receivedM: opBahtToM(received), arM: opBahtToM(ar),
      receivedPct: contract ? received / contract * 100 : null,
      arPct: contract ? ar / contract * 100 : null,
      signed, pendingSign: Math.max(0, scoped.length - signed),
      fyAvailable,
    };
  }

  // รวมทุกอย่างเป็น "โมเดลของหน้า 1 หน้า" — ปิดงวดแล้วอ่านจาก frozen แทน
  // ลำดับความน่าเชื่อ: ไฟล์จริงของฝ่ายนั้น -> ค่าที่กรอกมือ -> ทะเบียนในเว็บ
  function opPick() {
    for (let i = 0; i < arguments.length; i++) {
      const v = arguments[i];
      if (v != null && v !== '' && !(Array.isArray(v) && !v.length)) return v;
    }
    return null;
  }
  function opModel(period, auto) {
    const A = (period.status === 'final' && period.frozen) ? period.frozen : auto;
    const S = period.sales, V = period.service, E = period.eng || {}, C = period.cumulative || {};
    const inSys = opPick(opNum(V.inSystem), A.projectsInSystem);
    const pctOf = n => (inSys && n != null) ? (n / inSys * 100) : null;

    const contractM = opPick(opNum(E.contractM), A.contractM);
    const costM = opPick(opNum(E.costM), opNum(S.totalCostM));
    const grossM = opPick(opNum(E.grossM), (contractM != null && costM != null) ? contractM - costM : null);
    const receivedM = opPick(opNum(E.receivedM), A.receivedM);
    const arM = (contractM != null && receivedM != null) ? contractM - receivedM : A.arM;
    const workM = opPick(opNum(E.deliveredM), opNum(S.contractWorkValueM));
    const cumN = opPick(opNum(C.n), opNum(V.installedTotal));
    const cumM = opPick(opNum(C.valueM), opNum(V.cumulativeValueM));

    const budgetByFy = {};
    (S.budget || []).forEach(b => {
      const fy = String(b.fy || '').trim(); if (!fy) return;
      if (!budgetByFy[fy]) budgetByFy[fy] = { fy, projects: 0, totalM: 0, sources: [] };
      const g = budgetByFy[fy];
      g.projects = Math.max(g.projects, opNum(b.projects) || 0);
      const amt = opNum(b.amountM) || 0;
      g.totalM += amt;
      if (String(b.source || '').trim()) g.sources.push({ name: String(b.source).trim(), amountM: amt });
    });
    const budgetGroups = Object.keys(budgetByFy).sort().reverse().map(k => budgetByFy[k]);

    const engSigned = (E.signedByCompany || []).filter(r => String(r.company || '').trim());
    const signedRows = engSigned.length ? engSigned : (S.signedByCompany || []).filter(r => String(r.company || '').trim());
    const blCoRows = (S.backlogByCompany || []).filter(r => String(r.company || '').trim());
    const blTpRows = (S.backlogByType || []).filter(r => String(r.type || '').trim());

    return {
      period, asOf: period.asOf, status: period.status,
      inSys, scopedCount: opPick(engSigned.length ? opSum(engSigned, 'projects') : null, A.scopedCount),
      contractM, receivedM, arM,
      receivedPct: (contractM && receivedM != null) ? receivedM / contractM * 100 : A.receivedPct,
      arPct: (contractM && arM != null) ? arM / contractM * 100 : A.arPct,
      contractWorkM: workM,
      deliveredPct: (contractM && workM != null) ? workM / contractM * 100 : opNum(S.deliveredPct),
      costM, costPct: (contractM && costM != null) ? costM / contractM * 100 : null,
      grossM, grossPct: (contractM && grossM != null) ? grossM / contractM * 100 : null,
      installedTotal: cumN, provinces: opNum(V.provinces),
      cumulativeValueM: cumM,
      active: opNum(V.active), activePct: pctOf(opNum(V.active)),
      inactive: opNum(V.inactive), inactivePct: pctOf(opNum(V.inactive)),
      warrantyExpired: opPick(opNum(V.warrantyExpired), opNum(S.warrantyExpired)),
      warrantyPct: pctOf(opPick(opNum(V.warrantyExpired), opNum(S.warrantyExpired))),
      signedRows, signedTotalN: opSum(signedRows, 'projects'), signedTotalM: opSum(signedRows, 'amountM'),
      signedDone: opNum(S.signedDone), signedPending: opNum(S.signedPending),
      signedPendingNote: String(S.signedPendingNote || '').trim(),
      budgetGroups, budgetTotalM: budgetGroups.reduce((s, g) => s + g.totalM, 0),
      blCoRows, blCoN: opSum(blCoRows, 'projects'), blCoM: opSum(blCoRows, 'amountM'),
      blTpRows, blTpN: opSum(blTpRows, 'projects'), blTpM: opSum(blTpRows, 'amountM'),
    };
  }

  // ช่องที่ยังว่าง (เช็กลิสต์ก่อนปิดงวด)
  function opMissing(period) {
    const S = period.sales, V = period.service, out = [];
    const need = (v, label, who) => { if (v == null || v === '' || (Array.isArray(v) && !v.length)) out.push({ label, who }); };
    const E = period.eng || {}, C = period.cumulative || {};
    need(opPick(E.signedByCompany, S.signedByCompany), 'ลงนามรายบริษัท', 'วิศวะ');
    need(opPick(E.deliveredM, S.contractWorkValueM), 'มูลค่าสัญญางาน (ส่งมอบ)', 'วิศวะ');
    need(opPick(E.costM, S.totalCostM), 'ต้นทุนรวม', 'วิศวะ');
    need(opPick(V.warrantyExpired, S.warrantyExpired), 'จำนวนโครงการหมดประกัน', 'เซอร์วิส');
    need(S.budget, 'งบที่คาดว่าจะได้รับ', 'ฝ่ายขาย');
    need(S.backlogByCompany, 'Backlog รายบริษัท', 'ฝ่ายขาย');
    need(S.backlogByType, 'Backlog รายประเภท', 'ฝ่ายขาย');
    need(opPick(C.n, V.installedTotal), 'โครงการติดตั้งสะสม', 'Project Control ทุกปี');
    need(opPick(C.valueM, V.cumulativeValueM), 'มูลค่าโครงการรวมสะสม', 'Project Control ทุกปี');
    need(V.inSystem, 'โครงการในระบบ', 'เซอร์วิส');
    need(V.provinces, 'จำนวนจังหวัด', 'เซอร์วิส');
    need(V.active, 'โครงการที่เปิดใช้งาน', 'เซอร์วิส');
    need(V.inactive, 'โครงการที่ปิดใช้งาน', 'เซอร์วิส');
    return out;
  }

  /* ══════════════════════════════════════════════════════════════════════
     เทมเพลต Excel — สร้าง
     ★ โครงทุกชีตเหมือนกัน: แถว1 หัวเรื่อง · แถว2 หัวไทย · แถว3 คำอธิบาย
       · แถว4 key (เครื่องอ่าน) · แถว5+ ข้อมูล
     ══════════════════════════════════════════════════════════════════════ */
  const OP_COLS = {
    signed: [
      { key: 'company', label: 'บริษัท', hint: OP_COMPANIES.join(' / '), w: 16 },
      { key: 'projects', label: 'จำนวนโครงการ', hint: 'ตัวเลข', w: 16 },
      { key: 'amountM', label: 'มูลค่า (ล้านบาท)', hint: 'เช่น 353.4', w: 20 },
    ],
    backlogCo: [
      { key: 'company', label: 'บริษัท', hint: OP_COMPANIES.join(' / '), w: 16 },
      { key: 'projects', label: 'จำนวนโครงการ', hint: 'ตัวเลข', w: 16 },
      { key: 'amountM', label: 'มูลค่า (ล้านบาท)', hint: 'เช่น 930.1', w: 20 },
    ],
    backlogType: [
      { key: 'type', label: 'ประเภทผลิตภัณฑ์', hint: OP_TYPES.join(' / '), w: 22 },
      { key: 'projects', label: 'จำนวนโครงการ', hint: 'ตัวเลข', w: 16 },
      { key: 'amountM', label: 'มูลค่า (ล้านบาท)', hint: 'เช่น 1800', w: 20 },
    ],
    budget: [
      { key: 'fy', label: 'ปีงบ (พ.ศ.)', hint: 'เช่น 2570', w: 14 },
      { key: 'projects', label: 'จำนวนโครงการของปีงบ', hint: 'ใส่เลขเดียวกันทุกแถวของปีงบนั้น', w: 26 },
      { key: 'source', label: 'แหล่งงบ', hint: OP_BUDGET_SOURCES.join(' / '), w: 20 },
      { key: 'amountM', label: 'มูลค่า (ล้านบาท)', hint: 'ยอดรวมปีงบ = ผลบวกทุกแหล่ง', w: 22 },
    ],
    fields: [
      { key: '_label', label: 'รายการ', hint: '', w: 40 },
      { key: '_value', label: 'ค่า (กรอกช่องนี้)', hint: 'ตัวเลข ไม่ต้องใส่ comma', w: 20 },
      { key: '_hint', label: 'คำอธิบาย', hint: '', w: 46 },
      { key: '_key', label: 'key (ห้ามลบคอลัมน์นี้)', hint: '', w: 24 },
    ],
  };

  const OP_SALES_FIELDS = [
    { key: 'contractWorkValueM', label: 'มูลค่าสัญญางาน (ล้านบาท)', hint: 'บล็อก 2 · ยังไม่ได้ยืนยันว่าดึงจากระบบตรงไหน จึงให้กรอกไปก่อน' },
    { key: 'deliveredPct', label: '% ส่งมอบงาน', hint: 'บล็อก 2 · เช่น 88.74' },
    { key: 'totalCostM', label: 'ต้นทุนรวม (ล้านบาท)', hint: 'บล็อก 2 · ระบบใช้คำนวณกำไรขั้นต้นให้อัตโนมัติ' },
    { key: 'warrantyExpired', label: 'จำนวนโครงการที่หมดประกัน', hint: 'บล็อก 1 · เป็นจำนวนโครงการ ระบบคิด % ให้เอง' },
    { key: 'signedDone', label: 'งานลงนามแล้วเสร็จ (โครงการ)', hint: 'บล็อก 3 · เช่น 295' },
    { key: 'signedPending', label: 'รอลงนาม (โครงการ)', hint: 'บล็อก 3 · เช่น 1' },
    { key: 'signedPendingNote', label: 'รอลงนาม — หมายเหตุ', hint: 'บล็อก 3 · เช่น INS', text: true },
  ];
  const OP_SERVICE_FIELDS = [
    { key: 'installedTotal', label: 'โครงการติดตั้งสะสม (ตั้งแต่เริ่มต้น)', hint: 'บล็อก 1 · เช่น 1892' },
    { key: 'provinces', label: 'จำนวนจังหวัดที่มีโครงการ', hint: 'บล็อก 1 · เช่น 66' },
    { key: 'cumulativeValueM', label: 'มูลค่าโครงการรวมสะสม (ล้านบาท)', hint: 'บล็อก 1 · เช่น 6060 (= ฿6.06B)' },
    { key: 'active', label: 'โครงการที่เปิดใช้งาน', hint: 'บล็อก 1 · เช่น 1287 · ระบบคิด % ให้เอง' },
    { key: 'inactive', label: 'โครงการที่ปิดใช้งาน', hint: 'บล็อก 1 · เช่น 42 · เป้าไม่เกิน 5%' },
  ];

  const OP_STY = {
    title: { font: { bold: true, sz: 13, color: { rgb: '1F56B8' } } },
    head: { font: { bold: true, sz: 11, color: { rgb: 'FFFFFF' } }, fill: { fgColor: { rgb: '2A6FDB' } }, alignment: { vertical: 'center', wrapText: true } },
    hint: { font: { sz: 9, italic: true, color: { rgb: '7A879B' } }, alignment: { wrapText: true } },
    key: { font: { sz: 8, color: { rgb: 'B6C0CE' } }, fill: { fgColor: { rgb: 'F1F4F9' } } },
    fill: { fill: { fgColor: { rgb: 'FFF7DB' } }, border: { top: { style: 'thin', color: { rgb: 'E3E9F2' } }, bottom: { style: 'thin', color: { rgb: 'E3E9F2' } }, left: { style: 'thin', color: { rgb: 'E3E9F2' } }, right: { style: 'thin', color: { rgb: 'E3E9F2' } } } },
    ro: { font: { color: { rgb: '5A6B86' } } },
  };

  function opSetSty(ws, r, c, sty) {
    const addr = window.XLSX.utils.encode_cell({ r, c });
    if (!ws[addr]) ws[addr] = { t: 's', v: '' };
    ws[addr].s = Object.assign({}, ws[addr].s, sty);
  }

  // สร้างชีตแบบตาราง (แถวข้อมูลกรอกอิสระ)
  function opTableSheet(title, cols, rows, blankRows) {
    const X = window.XLSX;
    const aoa = [
      [title],
      cols.map(c => c.label),
      cols.map(c => c.hint || ''),
      cols.map(c => c.key),
    ];
    (rows || []).forEach(r => aoa.push(cols.map(c => (r[c.key] == null ? '' : r[c.key]))));
    for (let i = 0; i < (blankRows == null ? 10 : blankRows); i++) aoa.push(cols.map(() => ''));
    const ws = X.utils.aoa_to_sheet(aoa);
    ws['!cols'] = cols.map(c => ({ wch: c.w || 18 }));
    ws['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: Math.max(0, cols.length - 1) } }];
    ws['!rows'] = [{ hpt: 22 }, { hpt: 24 }, { hpt: 20 }, { hpt: 12 }];
    opSetSty(ws, 0, 0, OP_STY.title);
    cols.forEach((c, ci) => {
      opSetSty(ws, 1, ci, OP_STY.head);
      opSetSty(ws, 2, ci, OP_STY.hint);
      opSetSty(ws, 3, ci, OP_STY.key);
      for (let r = 4; r < aoa.length; r++) opSetSty(ws, r, ci, OP_STY.fill);
    });
    return ws;
  }

  // สร้างชีตแบบ "รายการ–ค่า" (ใช้โครงเดียวกับตาราง → parser ตัวเดียวอ่านได้ทั้งคู่)
  function opFieldSheet(title, fields, obj) {
    const cols = OP_COLS.fields;
    const rows = fields.map(f => ({
      _label: f.label,
      _value: (obj && obj[f.key] != null && obj[f.key] !== '') ? obj[f.key] : '',
      _hint: f.hint || '',
      _key: f.key,
    }));
    const ws = opTableSheet(title, cols, rows, 0);
    // ล็อกสายตา: ช่องที่ต้องกรอกคือคอลัมน์ B เท่านั้น
    for (let r = 4; r < 4 + rows.length; r++) {
      opSetSty(ws, r, 0, OP_STY.ro);
      opSetSty(ws, r, 2, OP_STY.hint);
      opSetSty(ws, r, 3, OP_STY.key);
    }
    return ws;
  }

  function opMetaSheet(period, dept) {
    const X = window.XLSX;
    const aoa = [
      ['Water POG · One-Page ผู้บริหาร — ชีตนี้ระบบใช้ตรวจ ห้ามแก้/ห้ามลบ'],
      ['key', 'value'],
      ['period', period.id],
      ['dept', dept],
      ['template_version', String(OP_TPL_VERSION)],
      ['as_of', period.asOf || ''],
      ['generated_at', new Date().toISOString()],
      ['generated_by', opUser()],
    ];
    const ws = X.utils.aoa_to_sheet(aoa);
    ws['!cols'] = [{ wch: 20 }, { wch: 40 }];
    opSetSty(ws, 0, 0, OP_STY.title);
    return ws;
  }

  function opRefSheet() {
    const X = window.XLSX;
    const n = Math.max(OP_COMPANIES.length, OP_TYPES.length, OP_BUDGET_SOURCES.length);
    const aoa = [
      ['รายการที่ยอมรับ — ก๊อปไปวางในช่องได้เลย (พิมพ์ต่างตัวพิมพ์เล็ก/ใหญ่ได้ ระบบจับให้)'],
      ['บริษัท', 'ประเภทผลิตภัณฑ์', 'แหล่งงบ'],
    ];
    for (let i = 0; i < n; i++) aoa.push([OP_COMPANIES[i] || '', OP_TYPES[i] || '', OP_BUDGET_SOURCES[i] || '']);
    const ws = X.utils.aoa_to_sheet(aoa);
    ws['!cols'] = [{ wch: 18 }, { wch: 22 }, { wch: 18 }];
    ws['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }];
    opSetSty(ws, 0, 0, OP_STY.title);
    [0, 1, 2].forEach(c => opSetSty(ws, 1, c, OP_STY.head));
    return ws;
  }

  function opSalesWorkbook(period) {
    const X = window.XLSX, S = period.sales;
    const wb = X.utils.book_new();
    X.utils.book_append_sheet(wb, opMetaSheet(period, 'sales'), '_meta');
    X.utils.book_append_sheet(wb, opTableSheet('1 · ลงนามรายบริษัท — งวด ' + opPeriodLabel(period.id), OP_COLS.signed, S.signedByCompany), '1-ลงนามรายบริษัท');
    X.utils.book_append_sheet(wb, opFieldSheet('2 · ตัวเลขการเงิน/สถานะ ที่ต้องกรอกเอง', OP_SALES_FIELDS, S), '2-ตัวเลขที่กรอกเอง');
    X.utils.book_append_sheet(wb, opTableSheet('3 · งบที่คาดว่าจะได้รับเพิ่ม', OP_COLS.budget, S.budget), '3-งบคาดว่าจะได้รับ');
    X.utils.book_append_sheet(wb, opTableSheet('4 · Backlog แยกตามบริษัท', OP_COLS.backlogCo, S.backlogByCompany), '4-Backlog รายบริษัท');
    X.utils.book_append_sheet(wb, opTableSheet('5 · Backlog แยกตามประเภทผลิตภัณฑ์', OP_COLS.backlogType, S.backlogByType), '5-Backlog รายประเภท');
    X.utils.book_append_sheet(wb, opRefSheet(), 'รายการที่ยอมรับ');
    return wb;
  }
  function opBuildSalesTemplate(period) {
    window.XLSX.writeFile(opSalesWorkbook(period), 'OnePage_ฝ่ายขาย_' + period.id + '.xlsx');
  }

  function opServiceWorkbook(period) {
    const X = window.XLSX;
    const wb = X.utils.book_new();
    X.utils.book_append_sheet(wb, opMetaSheet(period, 'service'), '_meta');
    X.utils.book_append_sheet(wb, opFieldSheet('ฝ่ายเซอร์วิส · สถานะการติดตั้ง — งวด ' + opPeriodLabel(period.id), OP_SERVICE_FIELDS, period.service), 'ฝ่ายเซอร์วิส');
    return wb;
  }
  function opBuildServiceTemplate(period) {
    window.XLSX.writeFile(opServiceWorkbook(period), 'OnePage_ฝ่ายเซอร์วิส_' + period.id + '.xlsx');
  }

  /* ══════════════════════════════════════════════════════════════════════
     เทมเพลต Excel — อ่านกลับ
     ★ หาแถวหัวตารางด้วยการสแกน (คีย์ก่อน → ถ้าไม่เจอใช้หัวไทย) ไม่ยึดตำแหน่ง
     ══════════════════════════════════════════════════════════════════════ */
  function opSheetAoa(ws) {
    if (!ws) return [];
    return window.XLSX.utils.sheet_to_json(ws, { header: 1, defval: '', raw: false, blankrows: true }) || [];
  }
  function opFindSheet(wb, names) {
    const want = names.map(n => String(n).toLowerCase().replace(/\s|-|_/g, ''));
    return (wb.SheetNames || []).find(sn => {
      const t = String(sn).toLowerCase().replace(/\s|-|_/g, '');
      return want.some(w => t.indexOf(w) >= 0);
    }) || null;
  }
  function opReadTable(ws, cols) {
    const aoa = opSheetAoa(ws);
    if (!aoa.length) return null;
    const keys = cols.map(c => c.key), labels = cols.map(c => c.label);
    let hi = -1, byLabel = false;
    for (let i = 0; i < Math.min(aoa.length, 25); i++) {
      const row = (aoa[i] || []).map(c => String(c == null ? '' : c).trim());
      if (row.some(c => keys.indexOf(c) >= 0)) { hi = i; break; }
    }
    if (hi < 0) {
      for (let i = 0; i < Math.min(aoa.length, 25); i++) {
        const row = (aoa[i] || []).map(c => String(c == null ? '' : c).trim());
        if (row.some(c => labels.indexOf(c) >= 0)) { hi = i; byLabel = true; break; }
      }
    }
    if (hi < 0) return null;
    const idx = {};
    (aoa[hi] || []).forEach((c, ci) => {
      const t = String(c == null ? '' : c).trim(); if (!t) return;
      const col = byLabel ? cols.find(x => x.label === t) : cols.find(x => x.key === t);
      if (col && idx[col.key] == null) idx[col.key] = ci;
    });
    if (!Object.keys(idx).length) return null;
    // ข้อความที่เป็น "หัว/คำอธิบาย/คีย์" — ถ้าทั้งแถวมีแต่พวกนี้ = ไม่ใช่ข้อมูล
    const noise = new Set();
    cols.forEach(c => { noise.add(c.key); noise.add(c.label); if (c.hint) noise.add(c.hint); });
    const out = [];
    for (let i = hi + 1; i < aoa.length; i++) {
      const row = aoa[i] || [];
      const obj = {}; let any = false, allNoise = true;
      Object.keys(idx).forEach(k => {
        const v = row[idx[k]];
        obj[k] = v == null ? '' : String(v).trim();
        if (obj[k] !== '') { any = true; if (!noise.has(obj[k])) allNoise = false; }
      });
      if (any && !allNoise) out.push(obj);
    }
    return out;
  }
  function opReadFields(ws, fields) {
    const rows = opReadTable(ws, OP_COLS.fields);
    if (!rows) return null;
    const byKey = {};
    rows.forEach(r => { const k = String(r._key || '').trim(); if (k) byKey[k] = r._value; });
    // สำรอง: ถ้าคอลัมน์ key ถูกลบ → จับด้วยชื่อรายการภาษาไทย
    if (!Object.keys(byKey).length) {
      rows.forEach(r => {
        const lb = String(r._label || '').trim();
        const f = fields.find(x => x.label === lb);
        if (f) byKey[f.key] = r._value;
      });
    }
    const out = {};
    fields.forEach(f => {
      const raw = byKey[f.key];
      if (raw == null || String(raw).trim() === '') { out[f.key] = null; return; }
      out[f.key] = f.text ? String(raw).trim() : opNum(raw);
    });
    return out;
  }
  function opReadMeta(wb) {
    const sn = opFindSheet(wb, ['_meta', 'meta']);
    if (!sn) return null;
    const aoa = opSheetAoa(wb.Sheets[sn]);
    const out = {};
    aoa.forEach(row => {
      const k = String((row || [])[0] == null ? '' : row[0]).trim();
      const v = String((row || [])[1] == null ? '' : row[1]).trim();
      if (k) out[k] = v;
    });
    return out;
  }
  // จับคู่รหัสกับรายการที่ยอมรับแบบไม่สนตัวพิมพ์/ช่องว่าง
  function opMatchCode(v, list) {
    const t = String(v || '').trim();
    if (!t) return { code: '', ok: false };
    const norm = s => String(s).toUpperCase().replace(/[\s.\-_]/g, '');
    const hit = list.find(x => norm(x) === norm(t));
    return hit ? { code: hit, ok: true } : { code: t, ok: false };
  }

  function opParseSales(wb, period) {
    const warn = [], meta = opReadMeta(wb);
    const res = { sales: {}, warn, meta };
    if (!meta) warn.push('ไฟล์นี้ไม่มีชีต _meta — อาจไม่ใช่เทมเพลตของระบบ (จะลองอ่านต่อให้)');
    else {
      if (meta.dept && meta.dept !== 'sales') warn.push('⚠ ไฟล์นี้เป็นเทมเพลตของ "' + meta.dept + '" ไม่ใช่ฝ่ายขาย');
      if (meta.period && meta.period !== period.id) warn.push('⚠ ไฟล์นี้เป็นงวด ' + opPeriodLabel(meta.period) + ' แต่กำลังอัปเข้างวด ' + opPeriodLabel(period.id));
      if (meta.template_version && +meta.template_version !== OP_TPL_VERSION) warn.push('⚠ เทมเพลตคนละเวอร์ชัน (ไฟล์ v' + meta.template_version + ' · ระบบ v' + OP_TPL_VERSION + ') — ควรโหลดเทมเพลตใหม่');
    }

    const readTbl = (names, cols, codeKey, codeList, label) => {
      const sn = opFindSheet(wb, names);
      if (!sn) { warn.push('ไม่พบชีต "' + label + '" — ข้ามส่วนนี้ (ค่าเดิมคงไว้)'); return null; }
      const rows = opReadTable(wb.Sheets[sn], cols);
      if (!rows) { warn.push('ชีต "' + label + '" หาหัวตารางไม่เจอ — ข้ามส่วนนี้'); return null; }
      const bad = [];
      const out = rows.map(r => {
        const o = {};
        cols.forEach(c => {
          if (c.key === codeKey) {
            const mm = opMatchCode(r[c.key], codeList);
            o[c.key] = mm.code;
            if (!mm.ok && mm.code) bad.push(mm.code);
          } else if (c.key === 'fy' || c.key === 'source') {
            o[c.key] = String(r[c.key] || '').trim();
          } else {
            o[c.key] = opNum(r[c.key]);
          }
        });
        return o;
      }).filter(o => (codeKey ? String(o[codeKey] || '').trim() : String(o.fy || '').trim()));
      if (bad.length) warn.push('ชีต "' + label + '" มีรหัสที่ไม่อยู่ในรายการที่ยอมรับ: ' + [...new Set(bad)].join(', ') + ' (รับไว้ตามที่พิมพ์)');
      return out;
    };

    const signed = readTbl(['1-ลงนามรายบริษัท', 'ลงนามรายบริษัท'], OP_COLS.signed, 'company', OP_COMPANIES, '1 · ลงนามรายบริษัท');
    if (signed) res.sales.signedByCompany = signed;

    const snF = opFindSheet(wb, ['2-ตัวเลขที่กรอกเอง', 'ตัวเลขที่กรอกเอง']);
    if (!snF) warn.push('ไม่พบชีต "2 · ตัวเลขที่กรอกเอง" — ข้ามส่วนนี้');
    else {
      const f = opReadFields(wb.Sheets[snF], OP_SALES_FIELDS);
      if (!f) warn.push('ชีต "2 · ตัวเลขที่กรอกเอง" อ่านไม่ออก — ข้ามส่วนนี้');
      else OP_SALES_FIELDS.forEach(x => { res.sales[x.key] = f[x.key]; });
    }

    const bud = readTbl(['3-งบคาดว่าจะได้รับ', 'งบคาดว่าจะได้รับ', 'งบ'], OP_COLS.budget, null, null, '3 · งบที่คาดว่าจะได้รับ');
    if (bud) res.sales.budget = bud;

    const blc = readTbl(['4-Backlogรายบริษัท', 'Backlogรายบริษัท'], OP_COLS.backlogCo, 'company', OP_COMPANIES, '4 · Backlog รายบริษัท');
    if (blc) res.sales.backlogByCompany = blc;

    const blt = readTbl(['5-Backlogรายประเภท', 'Backlogรายประเภท'], OP_COLS.backlogType, 'type', OP_TYPES, '5 · Backlog รายประเภท');
    if (blt) res.sales.backlogByType = blt;

    // ตรวจยอดที่ควรลงตัว
    if (res.sales.backlogByCompany && res.sales.backlogByType) {
      const a = opSum(res.sales.backlogByCompany, 'amountM'), b = opSum(res.sales.backlogByType, 'amountM');
      if (Math.abs(a - b) > 1) warn.push('⚠ Backlog รายบริษัท (' + opM(a) + ') ไม่เท่ากับรายประเภท (' + opM(b) + ') — ต่างกัน ' + opM(Math.abs(a - b)));
      const na = opSum(res.sales.backlogByCompany, 'projects'), nb = opSum(res.sales.backlogByType, 'projects');
      if (na !== nb) warn.push('⚠ จำนวนโครงการ Backlog ไม่ตรงกัน (รายบริษัท ' + opN0(na) + ' · รายประเภท ' + opN0(nb) + ')');
    }
    return res;
  }

  function opParseService(wb, period) {
    const warn = [], meta = opReadMeta(wb);
    const res = { service: {}, warn, meta };
    if (!meta) warn.push('ไฟล์นี้ไม่มีชีต _meta — อาจไม่ใช่เทมเพลตของระบบ (จะลองอ่านต่อให้)');
    else {
      if (meta.dept && meta.dept !== 'service') warn.push('⚠ ไฟล์นี้เป็นเทมเพลตของ "' + meta.dept + '" ไม่ใช่ฝ่ายเซอร์วิส');
      if (meta.period && meta.period !== period.id) warn.push('⚠ ไฟล์นี้เป็นงวด ' + opPeriodLabel(meta.period) + ' แต่กำลังอัปเข้างวด ' + opPeriodLabel(period.id));
      if (meta.template_version && +meta.template_version !== OP_TPL_VERSION) warn.push('⚠ เทมเพลตคนละเวอร์ชัน — ควรโหลดเทมเพลตใหม่');
    }
    const sn = opFindSheet(wb, ['ฝ่ายเซอร์วิส', 'service']);
    if (!sn) { warn.push('ไม่พบชีต "ฝ่ายเซอร์วิส"'); return res; }
    const f = opReadFields(wb.Sheets[sn], OP_SERVICE_FIELDS);
    if (!f) { warn.push('ชีต "ฝ่ายเซอร์วิส" อ่านไม่ออก'); return res; }
    OP_SERVICE_FIELDS.forEach(x => { res.service[x.key] = f[x.key]; });
    return res;
  }

  // เทียบของเดิม vs ของใหม่ → รายการเปลี่ยนแปลงให้ดูก่อนบันทึก
  function opDiff(period, parsed) {
    const rows = [];
    const push = (label, oldV, newV, fmt) => {
      const f = fmt || (v => (v == null || v === '' ? '—' : String(v)));
      if (String(oldV == null ? '' : oldV) === String(newV == null ? '' : newV)) return;
      rows.push({ label, oldV: f(oldV), newV: f(newV) });
    };
    if (parsed.sales) {
      OP_SALES_FIELDS.forEach(f => {
        if (!(f.key in parsed.sales)) return;
        push(f.label, period.sales[f.key], parsed.sales[f.key]);
      });
      const tbl = (key, label, codeKey) => {
        if (!parsed.sales[key]) return;
        const o = period.sales[key] || [], n = parsed.sales[key] || [];
        const sumO = opSum(o, 'amountM'), sumN = opSum(n, 'amountM');
        push(label + ' — จำนวนแถว', o.length, n.length);
        push(label + ' — มูลค่ารวม', sumO ? opM(sumO) : '—', sumN ? opM(sumN) : '—');
      };
      tbl('signedByCompany', 'ลงนามรายบริษัท', 'company');
      tbl('budget', 'งบที่คาดว่าจะได้รับ');
      tbl('backlogByCompany', 'Backlog รายบริษัท', 'company');
      tbl('backlogByType', 'Backlog รายประเภท', 'type');
    }
    if (parsed.service) {
      OP_SERVICE_FIELDS.forEach(f => {
        if (!(f.key in parsed.service)) return;
        push(f.label, period.service[f.key], parsed.service[f.key]);
      });
    }
    return rows;
  }

  /* ══════════════════════════════════════════════════════════════════════
     หน้า 1 หน้า — สร้างเป็น HTML string เดียว ใช้ทั้งพรีวิวบนจอและตอนพิมพ์
     (renderer เดียว → จอกับกระดาษไม่มีวันต่างกัน)
     ══════════════════════════════════════════════════════════════════════ */
  function opTile(label, value, sub, tone) {
    return '<div class="op-tile ' + (tone || '') + '">'
      + '<div class="op-tl">' + opEsc(label) + '</div>'
      + '<div class="op-tv">' + opEsc(value) + '</div>'
      + (sub ? '<div class="op-ts">' + opEsc(sub) + '</div>' : '')
      + '</div>';
  }
  function opMiniTable(head, rows, foot) {
    let h = '<table class="op-mt"><thead><tr>'
      + head.map((x, i) => '<th' + (i ? ' class="r"' : '') + '>' + opEsc(x) + '</th>').join('')
      + '</tr></thead><tbody>';
    rows.forEach(r => {
      h += '<tr>' + r.map((x, i) => '<td' + (i ? ' class="r num"' : '') + '>' + opEsc(x) + '</td>').join('') + '</tr>';
    });
    h += '</tbody>';
    if (foot) h += '<tfoot><tr>' + foot.map((x, i) => '<td' + (i ? ' class="r num"' : '') + '>' + opEsc(x) + '</td>').join('') + '</tr></tfoot>';
    return h + '</table>';
  }

  function opSheetHtml(M, logoUrl) {
    const P = M.period;
    const fyTxt = (P.fyList || []).length ? P.fyList.map(f => 25 + String(f).padStart(2, '0')).join('–') : 'ทุกปีงบ';

    // ── หัวรายงาน ────────────────────────────────────────────────
    let h = '<div class="op-sheet">';
    h += '<div class="op-hd">'
      + (logoUrl ? '<img class="op-logo" src="' + opEsc(logoUrl) + '" alt="">' : '')
      + '<div class="op-hdt">'
      + '<div class="op-h1">Water POG <span>· รายงานสรุปการเงินและมูลค่าโครงการ</span></div>'
      + '<div class="op-h2">' + opEsc(opPeriodLabel(P.id)) + ' · ข้อมูล ณ ' + opEsc(opThaiDate(M.asOf)) + ' · ปีงบประมาณ ' + opEsc(fyTxt) + '</div>'
      + '</div>'
      + '<div class="op-quote">“เพราะน้ำคือชีวิต<br>น้ำสะอาดคือสุขภาพที่ดีของชุมชน”</div>'
      + (M.status === 'final' ? '<div class="op-badge final">ปิดงวดแล้ว</div>' : '<div class="op-badge draft">ฉบับร่าง</div>')
      + '</div>';

    // ── 1 Portfolio & Status ─────────────────────────────────────
    h += '<div class="op-sec"><div class="op-st"><span class="n">1</span>Portfolio &amp; Status'
      + '<em>ภาพรวมโครงการสะสมทั้งหมด (ตั้งแต่เริ่มต้น)</em></div>'
      + '<div class="op-tiles t6">'
      + opTile('โครงการติดตั้งสะสม', opN0(M.installedTotal), (M.provinces != null ? M.provinces + ' จังหวัด' : ''), 'brand')
      + opTile('มูลค่าโครงการรวมสะสม', M.cumulativeValueM != null ? opB(M.cumulativeValueM) : '—', '')
      + opTile('โครงการในระบบ', opN0(M.inSys), 'จากทะเบียนโครงการ', 'auto')
      + opTile('เปิดใช้งาน', opN0(M.active), opPct(M.activePct), 'good')
      + opTile('ปิดใช้งาน', opN0(M.inactive), opPct(M.inactivePct) + (M.inactivePct != null && M.inactivePct <= 5 ? ' ✓ (เป้า ≤5%)' : ' (เป้า ≤5%)'), M.inactivePct != null && M.inactivePct <= 5 ? 'good' : 'warn')
      + opTile('หมดประกัน', opN0(M.warrantyExpired), opPct(M.warrantyPct))
      + '</div></div>';

    // ── 2 Financial Snapshot ─────────────────────────────────────
    h += '<div class="op-sec"><div class="op-st"><span class="n">2</span>Financial Snapshot'
      + '<em>ผลการเงินโครงการที่ดำเนินการ · ปีงบ ' + opEsc(fyTxt) + '</em></div>'
      + '<div class="op-tiles t6">'
      + opTile('มูลค่าสัญญาจ้าง', M.contractM != null ? opM(M.contractM) : '—', opN0(M.scopedCount) + ' โครงการ', 'brand')
      + opTile('มูลค่าสัญญางาน', M.contractWorkM != null ? opM(M.contractWorkM) : '—', M.deliveredPct != null ? 'ส่งมอบ ' + opPct(M.deliveredPct) : '')
      + opTile('ต้นทุนรวม', M.costM != null ? opM(M.costM) : '—', M.costPct != null ? 'ต้นทุน ' + opPct(M.costPct) : '', 'warn')
      + opTile('งานรับเงินแล้ว', M.receivedM != null ? opM(M.receivedM) : '—', M.receivedPct != null ? 'รับเงิน ' + opPct(M.receivedPct) : '', 'auto')
      + opTile('กำไรขั้นต้น', M.grossM != null ? opM(M.grossM) : '—', M.grossPct != null ? 'กำไร ' + opPct(M.grossPct) : '', 'good')
      + opTile('คงเหลือ (ค้างรับ)', M.arM != null ? opM(M.arM) : '—', M.arPct != null ? 'ค้างรับ ' + opPct(M.arPct) : '', 'warn')
      + '</div></div>';

    // ── 3/4/5 สามคอลัมน์ ────────────────────────────────────────
    h += '<div class="op-cols">';

    // 3 Sales
    h += '<div class="op-sec"><div class="op-st"><span class="n">3</span>Sales &amp; Operations<em>ลงนามรายบริษัท</em></div>';
    if (M.signedRows.length) {
      h += opMiniTable(['บริษัท', 'โครงการ', 'มูลค่า (M)'],
        M.signedRows.map(r => [r.company, opN0(opNum(r.projects)), (opNum(r.amountM) == null ? '—' : (Math.round(opNum(r.amountM) * 10) / 10).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }))]),
        ['รวมลงนาม', opN0(M.signedTotalN), (Math.round(M.signedTotalM * 10) / 10).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })]);
    } else h += '<div class="op-empty">ยังไม่ได้กรอก — อยู่ในเทมเพลตฝ่ายขาย</div>';
    if (M.signedDone != null || M.signedPending != null) {
      const tot = (M.signedDone || 0) + (M.signedPending || 0);
      const pct = tot ? (M.signedDone || 0) / tot * 100 : null;
      h += '<div class="op-note">แล้วเสร็จ <b>' + opN0(M.signedDone) + '</b> · รอลงนาม'
        + (M.signedPendingNote ? ' (' + opEsc(M.signedPendingNote) + ')' : '') + ' <b>' + opN0(M.signedPending) + '</b>'
        + (pct != null ? ' — ลงนามแล้ว <b>' + pct.toFixed(1) + '%</b>' : '') + '</div>';
    }
    h += '</div>';

    // 4 Budget
    h += '<div class="op-sec"><div class="op-st"><span class="n">4</span>Budget<em>งบที่คาดว่าจะได้รับเพิ่ม</em></div>';
    if (M.budgetGroups.length) {
      M.budgetGroups.forEach(g => {
        h += '<div class="op-bg"><div class="op-bgh"><span>ปี ' + opEsc(g.fy) + '</span>'
          + '<b>' + opN0(g.projects) + ' · ' + opM(g.totalM) + '</b></div>';
        if (g.sources.length) {
          h += '<div class="op-bgs">' + g.sources.map(s =>
            opEsc(s.name) + ' ' + (Math.round(s.amountM * 10) / 10).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })).join(' · ') + '</div>';
        }
        h += '</div>';
      });
      h += '<div class="op-tot">รวมงบที่คาดว่าจะได้รับ <b>' + opM(M.budgetTotalM) + '</b></div>';
    } else h += '<div class="op-empty">ยังไม่ได้กรอก — อยู่ในเทมเพลตฝ่ายขาย</div>';
    h += '</div>';

    // 5 Backlog
    h += '<div class="op-sec"><div class="op-st"><span class="n">5</span>Backlog<em>โครงการสำรองเพื่อเตรียมดำเนินการ</em></div>';
    if (M.blCoRows.length || M.blTpRows.length) {
      h += '<div class="op-bl">';
      if (M.blCoRows.length) {
        h += '<div>' + opMiniTable(['บริษัท', 'โครงการ', 'มูลค่า (M)'],
          M.blCoRows.map(r => [r.company, opN0(opNum(r.projects)), opN0(opNum(r.amountM))]),
          ['รวม', opN0(M.blCoN), opN0(M.blCoM)]) + '</div>';
      }
      if (M.blTpRows.length) {
        h += '<div>' + opMiniTable(['ประเภท', 'โครงการ', 'มูลค่า (M)'],
          M.blTpRows.map(r => [r.type, opN0(opNum(r.projects)), opN0(opNum(r.amountM))]),
          ['รวม', opN0(M.blTpN), opN0(M.blTpM)]) + '</div>';
      }
      h += '</div>';
      h += '<div class="op-hero"><div class="op-hl">มูลค่า BACKLOG รวมทั้งหมด</div>'
        + '<div class="op-hv">' + opN0(M.blCoM || M.blTpM) + ' <span>ล้านบาท</span></div>'
        + '<div class="op-hs">' + opN0(M.blCoN || M.blTpN) + ' โครงการสำรองที่พร้อมเดินหน้าต่อ</div></div>';
    } else h += '<div class="op-empty">ยังไม่ได้กรอก — อยู่ในเทมเพลตฝ่ายขาย</div>';
    h += '</div>';

    h += '</div>'; // op-cols

    // ── footer ──────────────────────────────────────────────────
    h += '<div class="op-ft"><span>Water POG · Financial One-Page Summary · ' + opEsc(opPeriodLabel(P.id)) + '</span>'
      + '<span>' + (P.meta && P.meta.finalizedAt ? 'ปิดงวดโดย ' + opEsc(P.meta.finalizedBy || '—') + ' · ' + opEsc(opWhen(P.meta.finalizedAt)) : 'ฉบับร่าง — ยังไม่ปิดงวด') + '</span>'
      + '<span>Confidential</span></div>';

    return h + '</div>';
  }

  const OP_SHEET_CSS = `
  .op-sheet{width:297mm;min-height:206mm;padding:7mm 8mm;background:#fff;color:#0d1f3a;
    font-family:'IBM Plex Sans Thai','Sarabun',system-ui,sans-serif;font-size:10.5px;line-height:1.45;
    display:flex;flex-direction:column;gap:6mm;box-sizing:border-box}
  .op-sheet *{box-sizing:border-box}
  .op-sheet .num{font-variant-numeric:tabular-nums}
  .op-hd{display:flex;align-items:center;gap:12px;border-bottom:2.5px solid #2a6fdb;padding-bottom:6px}
  .op-logo{height:38px;width:auto}
  .op-hdt{flex:1 1 auto;min-width:0}
  .op-h1{font-size:18px;font-weight:800;color:#1f56b8;letter-spacing:-.2px}
  .op-h1 span{font-size:13px;font-weight:600;color:#0d1f3a}
  .op-h2{font-size:10.5px;color:#5a6b86;margin-top:1px}
  .op-quote{font-size:10px;color:#1f56b8;font-style:italic;text-align:right;line-height:1.35;flex:0 0 auto}
  .op-badge{flex:0 0 auto;font-size:8.5px;font-weight:700;padding:3px 9px;border-radius:20px}
  .op-badge.draft{background:#fff3d6;color:#8a6100}
  .op-badge.final{background:#e2f5ec;color:#1f8a5b}
  .op-sec{display:flex;flex-direction:column;gap:5px;min-width:0}
  .op-st{display:flex;align-items:center;gap:6px;font-size:11.5px;font-weight:800;color:#1f56b8}
  .op-st .n{display:inline-grid;place-items:center;width:17px;height:17px;border-radius:5px;background:#2a6fdb;color:#fff;font-size:10px}
  .op-st em{font-style:normal;font-weight:500;font-size:10px;color:#5a6b86}
  .op-tiles{display:grid;gap:6px}
  .op-tiles.t6{grid-template-columns:repeat(6,1fr)}
  .op-tile{border:1px solid #e3e9f2;border-left:3px solid #c6d0de;border-radius:7px;padding:9px 11px;background:#fbfcfe;min-width:0}
  .op-tile.brand{border-left-color:#2a6fdb;background:#f4f8ff}
  .op-tile.auto{border-left-color:#0e9f9a;background:#f2fbfa}
  .op-tile.good{border-left-color:#1f8a5b;background:#f3fbf7}
  .op-tile.warn{border-left-color:#b8862b;background:#fffaf0}
  .op-tl{font-size:9.5px;color:#5a6b86;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .op-tv{font-size:21px;font-weight:800;letter-spacing:-.4px;font-variant-numeric:tabular-nums;line-height:1.15;margin-top:1px}
  .op-ts{font-size:9.5px;color:#5a6b86;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .op-cols{display:grid;grid-template-columns:1fr 1fr 1.25fr;gap:6mm;flex:1 1 auto;min-height:0}
  .op-mt{width:100%;border-collapse:collapse;font-size:10px}
  .op-mt th{background:#eef3fb;color:#1f56b8;font-weight:700;padding:4px 7px;text-align:left;border-bottom:1px solid #d7e0ee}
  .op-mt th.r,.op-mt td.r{text-align:right}
  .op-mt td{padding:3.5px 7px;border-bottom:1px solid #f0f3f8}
  .op-mt tfoot td{font-weight:800;border-top:1.5px solid #2a6fdb;border-bottom:none;background:#f7faff}
  .op-note{font-size:10px;color:#5a6b86;background:#f7faff;border-radius:5px;padding:4px 7px}
  .op-empty{font-size:10px;color:#94a3b8;font-style:italic;border:1px dashed #d7e0ee;border-radius:6px;padding:8px;text-align:center}
  .op-bg{border:1px solid #e3e9f2;border-radius:7px;padding:7px 10px;background:#fbfcfe}
  .op-bgh{display:flex;justify-content:space-between;align-items:baseline;gap:8px;font-size:10.5px}
  .op-bgh span{color:#5a6b86;font-weight:600}
  .op-bgh b{font-size:12.5px;color:#1f56b8;font-variant-numeric:tabular-nums}
  .op-bgs{font-size:9.5px;color:#5a6b86;margin-top:3px}
  .op-tot{display:flex;justify-content:space-between;align-items:baseline;font-size:10.5px;font-weight:700;
    border-top:1.5px solid #2a6fdb;padding-top:4px;margin-top:2px}
  .op-tot b{font-size:13.5px;color:#1f56b8;font-variant-numeric:tabular-nums}
  .op-bl{display:grid;grid-template-columns:1fr 1fr;gap:6px}
  .op-hero{margin-top:6px;border-radius:9px;padding:10px 13px;color:#fff;
    background:linear-gradient(135deg,#1f56b8,#2a6fdb);display:flex;flex-direction:column;gap:1px}
  .op-hl{font-size:9.5px;opacity:.9;font-weight:600}
  .op-hv{font-size:25px;font-weight:800;letter-spacing:-.5px;font-variant-numeric:tabular-nums;line-height:1.1}
  .op-hv span{font-size:12px;font-weight:600;opacity:.9}
  .op-hs{font-size:9.5px;opacity:.9}
  .op-ft{display:flex;justify-content:space-between;gap:10px;font-size:9px;color:#94a3b8;
    border-top:1px solid #e3e9f2;padding-top:4px;margin-top:auto}
  `;

  function opPrint(M, logoUrl) {
    const html = '<!doctype html><html lang="th"><head><meta charset="utf-8">'
      + '<title>Water POG One-Page ' + opEsc(opPeriodLabel(M.period.id)) + '</title>'
      + '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Thai:wght@400;500;600;700;800&display=swap">'
      + '<style>@page{size:A4 landscape;margin:0}html,body{margin:0;padding:0;background:#fff}'
      + OP_SHEET_CSS + '</style></head><body>' + opSheetHtml(M, logoUrl) + '</body></html>';
    const w = window.open('', '_blank');
    if (!w) { alert('เบราว์เซอร์บล็อกหน้าต่างใหม่ — อนุญาต pop-up ให้เว็บนี้ก่อน'); return; }
    w.document.open(); w.document.write(html); w.document.close();
    w.focus();
    setTimeout(() => { try { w.print(); } catch (e) {} }, 700);
  }

  /* ══════════════════════════════════════════════════════════════════════
     UI
     ══════════════════════════════════════════════════════════════════════ */
  function opBtn(p, label, onClick, kind, disabled) {
    const base = { height: 34, padding: '0 13px', borderRadius: 9, fontWeight: 700, fontSize: 12.5,
      cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? .45 : 1, border: '1px solid ' + p.line, whiteSpace: 'nowrap' };
    const sty = kind === 'primary'
      ? Object.assign({}, base, { border: 'none', background: 'linear-gradient(135deg,' + p.brand + ',' + p.brand2 + ')', color: '#fff' })
      : kind === 'gold'
        ? Object.assign({}, base, { border: 'none', background: p.gold, color: '#fff' })
        : Object.assign({}, base, { background: p.card, color: p.ink });
    return el('button', { onClick: disabled ? null : onClick, disabled: !!disabled, style: sty }, label);
  }
  function opCard(p, title, note, children, style) {
    return el('div', { style: Object.assign({ background: p.card, border: '1px solid ' + p.line, borderRadius: 14, padding: '16px 18px', boxShadow: p.shadow }, style || {}) },
      title ? el('div', { style: { display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 12, flexWrap: 'wrap' } },
        el('div', { style: { fontSize: 14.5, fontWeight: 800, color: p.ink } }, title),
        note ? el('div', { style: { fontSize: 11.5, color: p.sub } }, note) : null) : null,
      children);
  }

  // รายชื่อหัวคอลัมน์ของชีตหนึ่ง (ใช้เป็นตัวเลือกในหน้าจับคู่คอลัมน์)
  function opHeadsOf(wb, sheetName) {
    if (!wb || !sheetName || !wb.Sheets[sheetName] || !window.OPSRC) return [];
    const aoa = window.OPSRC.aoa(wb.Sheets[sheetName]);
    let best = 0, bestN = 0;
    for (let i = 0; i < Math.min(aoa.length, 30); i++) {
      const n = (aoa[i] || []).filter(c => c != null && String(c).trim() !== '').length;
      if (n > bestN) { bestN = n; best = i; }
    }
    return window.OPSRC.headerList(aoa, best).filter(Boolean);
  }

  function InvOnePage({ p, m, toast }) {
    const [rows, setRows] = opSt(null);          // ทุกแถวจาก DB (งวด + แถวตั้งค่า _map)
    const [showTpl, setShowTpl] = opSt(false);   // ทางสำรอง: เทมเพลตกรอกมือ
    const [pid, setPid] = opSt('');              // งวดที่เลือก
    const [busy, setBusy] = opSt(false);
    const [err, setErr] = opSt('');
    const [pending, setPending] = opSt(null);    // ผลอ่านไฟล์ที่รอยืนยัน
    const canEdit = opCanEdit(), canFinal = opCanFinalize();

    const logoUrl = opMemo(() => { try { return new URL('waterpog_Logo-02.png', location.href).href; } catch (e) { return ''; } }, []);

    // โหลดทุกงวด
    opEff(() => {
      let dead = false;
      if (!window.WTPData || !window.WTPData.fetchSheetRows) { setRows([]); return; }
      window.WTPData.fetchSheetRows(OP_TABLE).then(list => {
        if (dead) return;
        // แถวที่ id ขึ้นต้นด้วย _ = แถวตั้งค่า (เช่น _map) ไม่ใช่งวดรายงาน
        const norm = (list || []).map(r => (String(r.id || '').charAt(0) === '_' ? r : opNormalize(r)))
          .sort((a, b) => String(b.id).localeCompare(String(a.id)));
        setRows(norm);
        const firstPeriod = norm.find(r => String(r.id || '').charAt(0) !== '_');
        setPid(cur => cur || (firstPeriod ? firstPeriod.id : ''));
      }, e => { if (!dead) { setRows([]); setErr('โหลดข้อมูลงวดไม่สำเร็จ: ' + (e && e.message || '')); } });
      return () => { dead = true; };
    }, []);

    const periods = opMemo(() => (rows || []).filter(r => String(r.id || '').charAt(0) !== '_'), [rows]);
    const colMap = opMemo(() => (rows || []).find(r => r.id === '_map') || { id: '_map' }, [rows]);
    const period = opMemo(() => periods.find(r => r.id === pid) || null, [periods, pid]);
    const auto = opMemo(() => opAuto(m, period ? period.fyList : []), [m, period]);
    const model = opMemo(() => (period ? opModel(period, auto) : null), [period, auto]);
    const missing = opMemo(() => (period ? opMissing(period) : []), [period]);

    // ── บันทึกทุกงวดกลับ DB (writeTable ลบ id ที่ไม่ส่ง → ต้องส่งครบเสมอ) ──
    function save(next, okMsg) {
      if (!window.WTPData || !window.WTPData.writeTable) { setErr('ยังเชื่อมต่อฐานข้อมูลไม่ได้'); return Promise.resolve(false); }
      setBusy(true); setErr('');
      return window.WTPData.writeTable(OP_TABLE, next, r => r.id).then(() => {
        setRows(next); setBusy(false);
        if (okMsg && toast) toast(okMsg);
        return true;
      }, e => {
        setBusy(false); setErr('บันทึกไม่สำเร็จ: ' + (e && e.message || ''));
        return false;
      });
    }
    function patch(fn, okMsg) {
      if (!period) return;
      const next = (rows || []).map(r => (r.id === period.id ? fn(JSON.parse(JSON.stringify(r))) : r));
      save(next, okMsg);
    }

    function createPeriod() {
      const def = (() => { const d = new Date(); const pz = n => (n < 10 ? '0' : '') + n; return d.getFullYear() + '-' + pz(d.getMonth() + 1); })();
      const inp = window.prompt('งวดรายงาน (รูปแบบ YYYY-MM)\nเช่น 2026-06 = มิถุนายน 2026', def);
      if (!inp) return;
      const id = String(inp).trim();
      if (!/^\d{4}-\d{2}$/.test(id)) { alert('รูปแบบไม่ถูกต้อง — ต้องเป็น YYYY-MM เช่น 2026-06'); return; }
      if (periods.some(r => r.id === id)) { alert('มีงวด ' + opPeriodLabel(id) + ' อยู่แล้ว'); setPid(id); return; }
      const prev = periods.filter(r => r.id < id).sort((a, b) => String(b.id).localeCompare(String(a.id)))[0];
      const np = opEmptyPeriod(id);
      if (prev) {                                   // คัดลอกค่าจากงวดก่อนมาเป็นตั้งต้น
        np.sales = JSON.parse(JSON.stringify(prev.sales || {}));
        np.service = JSON.parse(JSON.stringify(prev.service || {}));
        np.eng = JSON.parse(JSON.stringify(prev.eng || {}));
        np.cumulative = JSON.parse(JSON.stringify(prev.cumulative || {}));
        np.fyList = (prev.fyList || []).slice();
      }
      const next = [np].concat(rows || []).sort((a, b) => String(b.id).localeCompare(String(a.id)));
      save(next, prev ? 'สร้างงวดใหม่ + คัดลอกตัวเลขจาก ' + opPeriodLabel(prev.id) + ' มาให้แล้ว' : 'สร้างงวดใหม่แล้ว').then(ok => { if (ok) setPid(id); });
    }
    function deletePeriod() {
      if (!period) return;
      if (!window.confirm('ลบงวด ' + opPeriodLabel(period.id) + ' ทั้งงวด?\nข้อมูลที่ 2 ฝ่ายกรอกไว้จะหายไปด้วย')) return;
      const next = (rows || []).filter(r => r.id !== period.id);
      const firstP = next.find(r => String(r.id || '').charAt(0) !== '_');
      save(next, 'ลบงวดแล้ว').then(ok => { if (ok) setPid(firstP ? firstP.id : ''); });
    }
    function toggleFy(fy) {
      const set = new Set(period.fyList || []);
      if (set.has(fy)) set.delete(fy); else set.add(fy);
      const nextFy = [...set].sort((a, b) => a - b);
      patch(r => { r.fyList = nextFy; return r; });
      // ★ ถ้ากำลังเปิดหน้าตรวจไฟล์อยู่ ต้องอ่านไฟล์ใหม่ตามปีงบที่เพิ่งเปลี่ยน
      //   ไม่งั้นผลเดิม (ที่ยังติดปีที่ไฟล์ไม่มี) ค้างอยู่ → ปุ่มยืนยันกดไม่ได้ตลอด
      setPending(pv => {
        if (!pv || pv.mode !== 'source' || !pv.wb) return pv;
        const perNext = Object.assign({}, period, { fyList: nextFy });
        const r = opReplan(pv.wb, pv.srcKey, pv.map, perNext);
        return Object.assign({}, pv, { plan: r.plan, ex: r.ex, warn: r.warn, showMap: !r.plan.ok });
      });
    }
    function finalize() {
      if (!period) return;
      if (missing.length && !window.confirm('ยังมี ' + missing.length + ' ช่องที่ยังไม่ได้กรอก\nปิดงวดต่อเลยไหม? (ช่องที่ว่างจะขึ้นเป็น “—” ในรายงาน)')) return;
      patch(r => {
        r.status = 'final';
        r.frozen = opAuto(m, r.fyList);
        r.meta = Object.assign({}, r.meta, { finalizedBy: opUser(), finalizedAt: new Date().toISOString() });
        return r;
      }, 'ปิดงวดแล้ว — ตัวเลขถูกแช่ไว้');
    }
    function reopen() {
      if (!window.confirm('เปิดงวดกลับเป็นฉบับร่าง?\nตัวเลขฝั่งระบบจะกลับไปอ่านสดจากทะเบียนโครงการ')) return;
      patch(r => { r.status = 'draft'; r.frozen = null; r.meta = Object.assign({}, r.meta, { finalizedBy: '', finalizedAt: null }); return r; }, 'เปิดงวดกลับเป็นร่างแล้ว');
    }

    /* ── อัปไฟล์จริงของแต่ละฝ่าย ────────────────────────────────
       อ่าน → จับคู่คอลัมน์ตามแผนที่ → ถ้าจับไม่ได้ให้เลือกเองในหน้าจอ
       → แสดงผลที่อ่านได้ + คำเตือน → ยืนยันแล้วค่อยบันทึก              */
    function opReplan(wb, srcKey, mapObj, per) {
      const S = window.OPSRC;
      const ctx = { years: (per.fyList || []).slice(), asOf: per.asOf, backlogFyFrom: S.BACKLOG_FY_FROM };
      const plan = S.plan(wb, srcKey, mapObj || {}, ctx);
      let ex = { payload: {}, stats: {} }, warn = [];
      if (plan.ok) {
        ex = S.extract(wb, srcKey, plan, ctx);
        const prevStats = (per.uploads && per.uploads[srcKey] && per.uploads[srcKey].stats) || null;
        warn = S.sanity(srcKey, ex.stats, prevStats);
      }
      return { plan, ex, warn };
    }
    function pickSourceFile(srcKey) {
      if (!window.XLSX || !window.OPSRC) { alert('ยังโหลดตัวอ่าน Excel ไม่เสร็จ — ลองรีเฟรชหน้า'); return; }
      // ★ ไฟล์ปีเก่า = นับยอดสะสมอย่างเดียว ไม่ต้องมีชีตของปีงบที่เลือก
      //   (เดิมต่อไปที่ตัวจัดการเดียวกับไฟล์วิศวะปีปัจจุบัน → ไม่ผ่านทุกครั้ง)
      if (srcKey === 'cumulative') { pickCumulativeFile(); return; }
      if (srcKey === 'eng' && !(period.fyList || []).length) {
        alert('เลือก "ปีงบที่รายงานครอบคลุม" ก่อน แล้วค่อยอัปไฟล์วิศวะ\n(ระบบต้องรู้ว่าจะอ่านชีตของปีไหน)'); return;
      }
      const inp = document.createElement('input');
      inp.type = 'file'; inp.accept = '.xlsx,.xls';
      inp.onchange = () => {
        const f = inp.files && inp.files[0]; if (!f) return;
        const fr = new FileReader();
        fr.onload = ev => {
          try {
            const wb = window.XLSX.read(ev.target.result, { type: 'array', cellDates: true });
            const guess = window.OPSRC.detect(wb);
            const mapObj = (colMap && colMap[srcKey]) || {};
            const r = opReplan(wb, srcKey, mapObj, period);
            const cum = window.OPSRC.cumulative(wb);
            setPending({
              mode: 'source', srcKey: srcKey, wb: wb, fileName: f.name, guess: guess,
              map: JSON.parse(JSON.stringify(mapObj)),
              plan: r.plan, ex: r.ex, warn: r.warn, cum: cum, showMap: !r.plan.ok,
            });
          } catch (e) { alert('อ่านไฟล์ไม่สำเร็จ: ' + (e && e.message || e)); }
        };
        fr.readAsArrayBuffer(f);
      };
      inp.click();
    }
    function pickCumulativeFile() {
      const inp = document.createElement('input');
      inp.type = 'file'; inp.accept = '.xlsx,.xls'; inp.multiple = true;
      inp.onchange = () => {
        const files = [...(inp.files || [])]; if (!files.length) return;
        const acc = { years: {}, sheets: [], files: [] };
        let done = 0;
        files.forEach(f => {
          const fr = new FileReader();
          fr.onload = ev => {
            try {
              const wb = window.XLSX.read(ev.target.result, { type: 'array', cellDates: true });
              const c = window.OPSRC.cumulative(wb);
              Object.assign(acc.years, c.years);
              acc.sheets = acc.sheets.concat(c.sheets);
              acc.files.push({ name: f.name, years: Object.keys(c.years).sort(), n: c.n });
            } catch (e) { acc.files.push({ name: f.name, err: (e && e.message) || String(e) }); }
            if (++done === files.length) {
              let n = 0, v = 0;
              Object.keys(acc.years).forEach(y => { n += acc.years[y].n || 0; v += acc.years[y].valueM || 0; });
              setPending({ mode: 'cum', cum: { years: acc.years, sheets: acc.sheets, n: n, valueM: Math.round(v * 100) / 100 },
                files: acc.files, fileName: files.map(x => x.name).join(' · ') });
            }
          };
          fr.readAsArrayBuffer(f);
        });
      };
      inp.click();
    }
    function applyCumulative(cum) {
      if (!cum || !cum.sheets || !cum.sheets.length) return;
      const next = (rows || []).map(row => {
        if (row.id !== period.id) return row;
        const r = JSON.parse(JSON.stringify(row));
        const cur = Object.assign({}, (r.cumulative && r.cumulative.years) || {}, cum.years);
        let n = 0, v = 0;
        Object.keys(cur).forEach(y => { n += cur[y].n || 0; v += cur[y].valueM || 0; });
        r.cumulative = { years: cur, n: n, valueM: Math.round(v * 100) / 100 };
        r.uploads = Object.assign({}, r.uploads, {
          cumulative: { by: opUser(), at: new Date().toISOString(), fileName: (pending && pending.fileName) || '', stats: { years: Object.keys(cur).sort(), n: n } },
        });
        return r;
      });
      setPending(null);
      save(next, 'บันทึกยอดสะสมแล้ว');
    }
    // เปลี่ยนแผนที่คอลัมน์/ชีต แล้วคำนวณใหม่ทันที
    function setMapping(kind, key, value) {
      setPending(pv => {
        if (!pv || pv.mode !== 'source') return pv;
        const mp = JSON.parse(JSON.stringify(pv.map || {}));
        if (!mp[kind]) mp[kind] = {};
        if (value) mp[kind][key] = value; else delete mp[kind][key];
        const r = opReplan(pv.wb, pv.srcKey, mp, period);
        return Object.assign({}, pv, { map: mp, plan: r.plan, ex: r.ex, warn: r.warn });
      });
    }
    function applySource() {
      const pd = pending; if (!pd || pd.mode !== 'source' || !pd.plan.ok) return;
      const k = pd.srcKey, pay = pd.ex.payload || {};
      // ★ ต้องเขียนงวด + แผนที่คอลัมน์ "ครั้งเดียว" ห้ามแยก 2 ครั้ง
      //   writeTable ส่งทั้งตารางทุกครั้ง → เขียนซ้อนกันจะทับข้อมูลที่เพิ่งบันทึก
      //   ด้วย rows เวอร์ชันเก่า (เจอจริงตอนทดสอบ: กดยืนยันแล้วขึ้น toast
      //   ว่าบันทึกแล้ว แต่ตัวเลขไม่เข้า)
      const mapRow = Object.assign({}, colMap, { id: '_map' });
      mapRow[k] = pd.map || {};
      const next = (rows || []).filter(r => r.id !== '_map').map(row => {
        if (row.id !== period.id) return row;
        const r = JSON.parse(JSON.stringify(row));
        if (k === 'sales') r.sales = Object.assign({}, r.sales, pay);
        else if (k === 'service') r.service = Object.assign({}, r.service, pay);
        else if (k === 'eng') r.eng = Object.assign({}, r.eng, pay);
        // ทุกไฟล์ที่มีชีต Main all → รวมเข้ายอดสะสม (บล็อก 1)
        if (pd.cum && pd.cum.sheets && pd.cum.sheets.length) {
          const cur = Object.assign({}, (r.cumulative && r.cumulative.years) || {}, pd.cum.years);
          let n = 0, v = 0;
          Object.keys(cur).forEach(y => { n += cur[y].n || 0; v += cur[y].valueM || 0; });
          r.cumulative = { years: cur, n: n, valueM: Math.round(v * 100) / 100 };
        }
        r.uploads = Object.assign({}, r.uploads, {
          [k]: { by: opUser(), at: new Date().toISOString(), fileName: pd.fileName, stats: pd.ex.stats || {} },
        });
        return r;
      }).concat([mapRow]);
      setPending(null);
      save(next, 'บันทึกข้อมูลจากไฟล์' + (OPSRC_LBL[k] || k) + 'แล้ว');
    }

    // ── อัปไฟล์เทมเพลต (ทางสำรอง) ──────────────────────────────
    function pickFile(dept) {
      if (!window.XLSX) { alert('ยังโหลดตัวอ่าน Excel ไม่เสร็จ — ลองรีเฟรชหน้า'); return; }
      const inp = document.createElement('input');
      inp.type = 'file'; inp.accept = '.xlsx,.xls';
      inp.onchange = () => {
        const f = inp.files && inp.files[0]; if (!f) return;
        const fr = new FileReader();
        fr.onload = ev => {
          try {
            const wb = window.XLSX.read(ev.target.result, { type: 'array', cellDates: false });
            const parsed = dept === 'sales' ? opParseSales(wb, period) : opParseService(wb, period);
            parsed.dept = dept; parsed.fileName = f.name;
            parsed.diff = opDiff(period, parsed);
            setPending(parsed);
          } catch (e) { alert('อ่านไฟล์ไม่สำเร็จ: ' + (e && e.message || e)); }
        };
        fr.readAsArrayBuffer(f);
      };
      inp.click();
    }
    function applyPending() {
      if (!pending) return;
      const dept = pending.dept;
      patch(r => {
        if (dept === 'sales') r.sales = Object.assign({}, r.sales, pending.sales);
        else r.service = Object.assign({}, r.service, pending.service);
        r.uploads = Object.assign({}, r.uploads, {
          [dept]: { by: opUser(), at: new Date().toISOString(), fileName: pending.fileName, templateVersion: (pending.meta && pending.meta.template_version) || '' },
        });
        return r;
      }, 'บันทึกข้อมูล' + (dept === 'sales' ? 'ฝ่ายขาย' : 'ฝ่ายเซอร์วิส') + 'แล้ว');
      setPending(null);
    }

    // ── render ─────────────────────────────────────────────────
    if (rows === null) return el('div', { style: { padding: 40, color: p.sub } }, 'กำลังโหลดงวดรายงาน…');

    const head = el('div', { className: 'no-print', style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginBottom: 14 } },
      el('div', { style: { fontSize: 12, color: p.sub, fontWeight: 600 } }, 'งวดรายงาน'),
      el('select', {
        value: pid, onChange: e => { setPid(e.target.value); setPending(null); },
        style: { height: 34, padding: '0 10px', borderRadius: 9, border: '1px solid ' + p.line, background: p.card, color: p.ink, fontSize: 13, fontWeight: 700, minWidth: 170 },
      }, [el('option', { key: '_', value: '' }, periods.length ? '— เลือกงวด —' : '— ยังไม่มีงวด —')]
        // ★ ต้องใช้ periods ไม่ใช่ rows — ไม่งั้นแถวตั้งค่า (_map) จะโผล่เป็นงวดในดรอปดาวน์
        .concat(periods.map(r => el('option', { key: r.id, value: r.id }, opPeriodLabel(r.id) + (r.status === 'final' ? ' · ปิดงวดแล้ว' : ' · ร่าง'))))),
      canEdit ? opBtn(p, '+ สร้างงวดใหม่', createPeriod, 'primary', busy) : null,
      period && canEdit && period.status !== 'final' ? opBtn(p, '🗑 ลบงวดนี้', deletePeriod, '', busy) : null,
      err ? el('div', { style: { color: p.bad, fontSize: 12.5, fontWeight: 600 } }, err) : null
    );

    if (!period) {
      return el('div', null, head,
        opCard(p, 'ยังไม่มีงวดรายงาน', null,
          el('div', { style: { fontSize: 13.5, color: p.sub, lineHeight: 1.7 } },
            el('div', null, 'กด “+ สร้างงวดใหม่” เพื่อเริ่มงวดแรก แล้วทำตามลำดับนี้:'),
            el('ol', { style: { margin: '10px 0 0 18px', padding: 0 } },
              el('li', null, 'เลือกปีงบที่รายงานครอบคลุม → ตัวเลขฝั่งระบบขึ้นเอง'),
              el('li', null, 'โหลดเทมเพลตฝ่ายขาย + ฝ่ายเซอร์วิส ส่งให้แต่ละฝ่ายกรอก'),
              el('li', null, 'อัปไฟล์กลับ ตรวจในหน้าพรีวิว แล้วยืนยัน'),
              el('li', null, 'ปิดงวด → กดพิมพ์ ได้ PDF หน้าเดียว')))));
    }

    const isFinal = period.status === 'final';
    const editable = canEdit && !isFinal;

    // แถบปีงบ
    const fyBar = opCard(p, 'ปีงบที่รายงานครอบคลุม', 'ตัวเลขฝั่งระบบ (บล็อก 2) จะนับเฉพาะปีงบที่เลือก · ไม่เลือกเลย = นับทุกปีงบ',
      el('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
        auto.fyAvailable.length ? auto.fyAvailable.map(fy => {
          const on = (period.fyList || []).indexOf(fy) >= 0;
          return el('button', {
            key: fy, onClick: editable ? () => toggleFy(fy) : null, disabled: !editable,
            style: { padding: '6px 12px', borderRadius: 8, fontSize: 12.5, fontWeight: 700, cursor: editable ? 'pointer' : 'default',
              border: '1px solid ' + (on ? 'transparent' : p.line), background: on ? 'linear-gradient(135deg,' + p.brand + ',' + p.brand2 + ')' : p.card, color: on ? '#fff' : p.sub },
          }, 'ปีงบ 25' + String(fy).padStart(2, '0'));
        }) : el('div', { style: { fontSize: 12.5, color: p.sub } }, 'ยังไม่มีข้อมูลปีงบในทะเบียนโครงการ')));

    /* ── สถานะข้อมูลของแต่ละฝ่าย ────────────────────────────────
       แยก 3 สถานะ เพราะ "สร้างงวดใหม่" คัดลอกตัวเลขจากงวดก่อนมาให้
       → มีข้อมูลอยู่ได้ทั้งที่ยังไม่ได้อัปไฟล์ในงวดนี้ ต้องบอกให้เห็น    */
    function opSrcState(k) {
      const u = period.uploads && period.uploads[k];
      if (u) return { s: 'up', u: u };
      const E = period.eng || {}, V = period.service || {}, S = period.sales || {}, C = period.cumulative || {};
      const hasOld =
        k === 'eng' ? (E.contractM != null || (E.signedByCompany || []).length > 0)
        : k === 'service' ? (V.inSystem != null || V.active != null)
        : k === 'sales' ? ((S.backlogByCompany || []).length > 0 || (S.budget || []).length > 0)
        : k === 'cumulative' ? (C.n != null) : false;
      return { s: hasOld ? 'old' : 'none' };
    }
    const OP_ST = {
      up:   { icon: '✓', text: 'อัปในงวดนี้แล้ว', col: 'good' },
      old:  { icon: '↩', text: 'ใช้ข้อมูลจากงวดก่อน — ยังไม่ได้อัปไฟล์ใหม่', col: 'gold' },
      none: { icon: '●', text: 'ยังไม่มีข้อมูล', col: 'bad' },
    };
    // ── การ์ดอัปไฟล์จริงของแต่ละฝ่าย ──
    const srcBox = (k) => {
      const D = (window.OPSRC && window.OPSRC.DEFS[k]) || {};
      const st = opSrcState(k), meta = OP_ST[st.s], col = p[meta.col];
      return el('div', { key: k, style: { border: '1px solid ' + col, borderLeft: '4px solid ' + col, borderRadius: 12, padding: '14px 16px', background: p.card2, display: 'flex', flexDirection: 'column', gap: 8 } },
        el('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
          el('div', { style: { fontSize: 13.5, fontWeight: 800, flex: 1 } }, (D.icon || '') + ' ' + (D.label || k)),
          el('span', { style: { fontSize: 11, fontWeight: 700, color: '#fff', background: col, borderRadius: 20, padding: '2px 9px', whiteSpace: 'nowrap' } }, meta.icon + ' ' + (st.s === 'up' ? 'อัปแล้ว' : st.s === 'old' ? 'งวดก่อน' : 'ยังไม่มี'))),
        el('div', { style: { fontSize: 11.5, color: p.sub, lineHeight: 1.5 } }, D.what || ''),
        el('div', { style: { fontSize: 11, color: p.sub, fontStyle: 'italic' } }, 'ไฟล์ที่ต้องใช้: ' + (D.fileHint || '')),
        el('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 2 } },
          opBtn(p, st.s === 'up' ? '⬆ อัปทับใหม่' : '⬆ อัปไฟล์', () => pickSourceFile(k), st.s === 'up' ? '' : 'primary', !editable)),
        el('div', { style: { fontSize: 11.5, fontWeight: 600, color: col, lineHeight: 1.5 } },
          st.s === 'up'
            ? el('span', null, '✓ ' + (st.u.fileName || 'อ่านแล้ว'),
                el('span', { style: { color: p.sub, fontWeight: 500 } }, '  · ' + opWhen(st.u.at) + ' โดย ' + (st.u.by || '—')))
            : meta.text));
    };
    const cum = period.cumulative || {};
    const cumYears = Object.keys(cum.years || {}).sort();
    const upN = ['eng', 'service', 'sales'].filter(k => opSrcState(k).s === 'up').length;
    const oldN = ['eng', 'service', 'sales'].filter(k => opSrcState(k).s === 'old').length;
    const srcCard = opCard(p, 'อัปไฟล์ที่แต่ละฝ่ายทำอยู่แล้ว', 'ไม่ต้องให้ใครกรอกอะไรใหม่ — ส่งไฟล์เดิมมาให้ระบบอ่าน',
      el('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
        el('div', { style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', padding: '9px 13px', borderRadius: 10, background: upN === 3 ? 'rgba(31,138,91,.10)' : 'rgba(184,134,43,.12)', border: '1px solid ' + (upN === 3 ? p.good : p.gold) } },
          el('b', { style: { fontSize: 13, color: upN === 3 ? p.good : p.gold } },
            upN === 3 ? '✓ งวดนี้อัปครบ 3 ฝ่ายแล้ว' : 'งวดนี้อัปแล้ว ' + upN + ' จาก 3 ฝ่าย'),
          oldN ? el('span', { style: { fontSize: 12, color: p.sub } },
            '· อีก ' + oldN + ' ฝ่ายยังใช้ตัวเลขจากงวดก่อนอยู่ (รายงานออกได้ แต่เลขจะไม่ใช่ของงวดนี้)') : null),
        el('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(255px,1fr))', gap: 12 } },
          ['eng', 'service', 'sales'].map(srcBox)),
        el('div', { style: { border: '1px dashed ' + p.line, borderRadius: 10, padding: '10px 14px', display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' } },
          el('div', { style: { fontSize: 12.5, fontWeight: 700 } }, '📚 ยอดสะสมทุกปีงบ'),
          el('div', { style: { fontSize: 11.5, color: p.sub, flex: '1 1 220px' } },
            cumYears.length
              ? 'อ่านแล้ว: ปีงบ ' + cumYears.map(y => '25' + y).join(', ') + ' · รวม ' + opN0(cum.n) + ' โครงการ · ' + opM(cum.valueM)
              : 'อัปไฟล์ Project Control ปีเก่า (63-64 / 65 / 66) — เลือกหลายไฟล์พร้อมกันได้ · ใช้คิด "โครงการติดตั้งสะสม" + "มูลค่าสะสม"'),
          opBtn(p, '⬆ อัปไฟล์ปีเก่า', () => pickSourceFile('cumulative'), '', !editable)),
        el('div', { style: { fontSize: 11.5, color: p.sub } },
          el('a', { href: '#', onClick: e => { e.preventDefault(); setShowTpl(!showTpl); }, style: { color: p.brand, fontWeight: 600 } },
            showTpl ? '▾ ซ่อนทางสำรอง (เทมเพลตกรอกมือ)' : '▸ ทางสำรอง: เทมเพลตกรอกมือ (ใช้เมื่อไฟล์ฝ่ายนั้นอ่านไม่ได้)'))));

    // เทมเพลต 2 ฝ่าย (ทางสำรอง)
    const deptBox = (dept, title, sub, dl, up) => {
      const u = period.uploads && period.uploads[dept];
      return el('div', { style: { border: '1px solid ' + p.line, borderRadius: 12, padding: '14px 16px', background: p.card2, display: 'flex', flexDirection: 'column', gap: 9 } },
        el('div', { style: { fontSize: 13.5, fontWeight: 800 } }, title),
        el('div', { style: { fontSize: 11.5, color: p.sub, lineHeight: 1.5 } }, sub),
        el('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
          opBtn(p, '⬇ โหลดเทมเพลต', dl, '', false),
          opBtn(p, '⬆ อัปไฟล์กลับ', up, 'primary', !editable)),
        el('div', { style: { fontSize: 11.5, fontWeight: 600, color: u ? p.good : p.gold } },
          u ? '✓ ส่งแล้ว · ' + opWhen(u.at) + ' โดย ' + (u.by || '—') + (u.fileName ? ' · ' + u.fileName : '') : '● ยังไม่ได้ส่งไฟล์ในงวดนี้'));
    };

    const tplCard = !showTpl ? null : opCard(p, 'ทางสำรอง · เทมเพลตกรอกมือ', 'ใช้เมื่อไฟล์ของฝ่ายนั้นเปลี่ยนโครงจนอ่านไม่ได้ — งวดจะได้ไม่ค้าง',
      el('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(280px,1fr))', gap: 12 } },
        deptBox('sales', 'ฝ่ายขาย', 'ลงนามรายบริษัท · งบที่คาดว่าจะได้รับ · Backlog · และ 3 ตัวเลขที่ยังยืนยันนิยามไม่ได้ (มูลค่าสัญญางาน · ต้นทุนรวม · หมดประกัน)',
          () => opBuildSalesTemplate(period), () => pickFile('sales')),
        deptBox('service', 'ฝ่ายเซอร์วิส', 'โครงการติดตั้งสะสม · จำนวนจังหวัด · มูลค่าสะสม · เปิด/ปิดใช้งาน',
          () => opBuildServiceTemplate(period), () => pickFile('service'))));

    // ── ตรวจก่อนบันทึก (โหมดไฟล์จริงของฝ่าย) ──
    const srcPending = (pending && pending.mode === 'source') ? pending : null;
    const srcDef = srcPending ? (window.OPSRC.DEFS[srcPending.srcKey] || {}) : null;
    const mapRow = (label, cur, opts, onPick, bad) => el('div', {
      style: { display: 'grid', gridTemplateColumns: 'minmax(150px,1fr) 16px minmax(170px,1.2fr)', gap: 8, alignItems: 'center', padding: '5px 0' },
    },
      el('div', { style: { fontSize: 12.5, color: bad ? p.bad : p.ink, fontWeight: bad ? 700 : 500 } }, label),
      el('div', { style: { color: p.sub, textAlign: 'center' } }, '→'),
      el('select', {
        value: cur || '', onChange: e => onPick(e.target.value),
        style: { height: 30, borderRadius: 8, border: '1px solid ' + (bad ? p.bad : p.line), background: p.card, color: p.ink, fontSize: 12, padding: '0 6px', width: '100%' },
      }, [el('option', { key: '_', value: '' }, bad ? '— หาไม่เจอ เลือกเอง —' : '— ไม่ระบุ —')]
        .concat((opts || []).map((o, i) => el('option', { key: i, value: o }, o)))));

    const srcMapUI = !srcPending ? null : (() => {
      const D = srcDef, pl = srcPending.plan, wb = srcPending.wb;
      const years = D.perYear ? (period.fyList || []) : [null];
      const badSheets = {}, badCols = {};
      (pl.missing || []).forEach(mm => { if (mm.kind === 'sheet') badSheets[mm.slot] = 1; else if (mm.field) badCols[mm.field + (mm.slot.indexOf(':') >= 0 ? mm.slot.slice(mm.slot.indexOf(':')) : '')] = 1; });
      const out = [];
      (D.sheets || []).forEach(sd => years.forEach(yy => {
        const slot = yy == null ? sd.key : (sd.key + ':' + yy);
        const curSheet = pl.sheets[slot] || (srcPending.map.sheets && srcPending.map.sheets[slot]) || '';
        out.push(el('div', { key: 'sh' + slot, style: { borderTop: '1px solid ' + p.line, paddingTop: 8, marginTop: 8 } },
          mapRow('📄 ' + sd.label + (yy != null ? ' · ปี ' + yy : ''), curSheet, wb.SheetNames,
            v => setMapping('sheets', slot, v), !!badSheets[slot])));
        const heads = curSheet ? opHeadsOf(wb, curSheet) : [];
        (D.fields || []).filter(f => f.sheet === sd.key).forEach(f => {
          const cinfo = pl.cols[f.k + (yy != null ? ':' + yy : '')] || {};
          const cur = (srcPending.map.cols && srcPending.map.cols[f.k]) || (cinfo.col >= 0 ? heads[cinfo.col] : '') || f.def;
          const bad = cinfo.col == null || cinfo.col < 0;
          out.push(el('div', { key: 'f' + f.k + slot }, mapRow('　' + f.label + (f.req ? '' : ' (ไม่บังคับ)'), cur, heads,
            v => setMapping('cols', f.k, v), bad && f.req)));
        });
        (D.kvRows || []).filter(r => r.sheet === sd.key).forEach(f => {
          const cinfo = pl.cols[f.k + (yy != null ? ':' + yy : '')] || {};
          const labels = curSheet ? (window.OPSRC.aoa(wb.Sheets[curSheet]).slice(0, 40).map(r => (r || [])[0]).filter(x => x != null && String(x).trim()).map(String)) : [];
          const cur = (srcPending.map.cols && srcPending.map.cols[f.k]) || f.def;
          out.push(el('div', { key: 'k' + f.k + slot }, mapRow('　' + f.label, cur, labels,
            v => setMapping('cols', f.k, v), (cinfo.row == null || cinfo.row < 0) && f.req)));
        });
      }));
      return out;
    })();

    const srcStats = !srcPending ? null : (() => {
      const st = srcPending.ex.stats || {}, k = srcPending.srcKey, L = [];
      const add = (a, b) => L.push([a, b]);
      if (k === 'sales') {
        add('อ่านได้', opN0(st.rows) + ' แถว');
        add('Backlog (ปีงบ ' + st.fyFrom + '+)', opN0(st.backlogN) + ' โครงการ · ' + opM(st.backlogM));
        add('งบที่คาดว่าจะได้รับ', opN0(st.budgetN) + ' โครงการ · ' + opM(st.budgetM));
      } else if (k === 'service') {
        add('โครงการในระบบ', opN0(st.rows));
        add('จังหวัด', opN0(st.provinces));
        add('เปิด / ปิดใช้งาน', opN0(st.active) + ' / ' + opN0(st.inactive));
        add('หมดประกัน', opN0(st.warrantyExpired) + '  (' + (st.warrantyFrom || '') + ')');
      } else {
        add('ปีงบที่อ่าน', (st.years || []).map(y => '25' + y).join(', ') || '—');
        const sk = (srcPending.plan && srcPending.plan.skippedYears) || [];
        if (sk.length) add('ข้ามปีงบ', sk.map(y => '25' + y).join(', ') + '  (ไฟล์ยังไม่มีชีตครบของปีนั้น)');
        add('มูลค่าสัญญา', opM(st.contractM));
        add('ต้นทุนรวม', opM(st.costM) + (st.costPct != null ? '  (' + st.costPct + '% ของสัญญา)' : ''));
        add('กำไรขั้นต้น', opM(st.grossM));
        add('ส่งมอบแล้ว', opM(st.deliveredM));
      }
      const c = srcPending.cum;
      if (c && c.sheets && c.sheets.length) add('ยอดสะสมที่พบในไฟล์', opN0(c.n) + ' โครงการ · ' + opM(c.valueM) + ' (ปี ' + Object.keys(c.years).sort().join(', ') + ')');
      return L;
    })();

    const srcCardPending = !srcPending ? null : opCard(p,
      'ตรวจก่อนบันทึก · ไฟล์' + (OPSRC_LBL[srcPending.srcKey] || ''), srcPending.fileName,
      el('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
        srcPending.guess && srcPending.guess !== srcPending.srcKey
          ? el('div', { style: { background: 'rgba(192,57,43,.10)', border: '1px solid ' + p.bad, borderRadius: 10, padding: '10px 12px', fontSize: 12.5, fontWeight: 600, color: p.bad } },
            '⚠ ไฟล์นี้หน้าตาเหมือนของ "' + (OPSRC_LBL[srcPending.guess] || srcPending.guess) + '" แต่กำลังอัปเข้าช่อง "' + (OPSRC_LBL[srcPending.srcKey] || '') + '"')
          : null,
        (!srcPending.plan.ok && srcPending.cum && srcPending.cum.sheets && srcPending.cum.sheets.length)
          ? el('div', { style: { background: 'rgba(31,86,184,.10)', border: '1px solid ' + p.brand, borderRadius: 10, padding: '12px 14px', fontSize: 12.5, lineHeight: 1.7 } },
            el('div', { style: { fontWeight: 700, color: p.brand, marginBottom: 4 } }, 'ไฟล์นี้เป็น "Project Control ปีเก่า" (พบปีงบ ' + Object.keys(srcPending.cum.years).sort().map(y => '25' + y).join(', ') + ')'),
            el('div', null, 'ไม่ต้องใช้ช่องฝ่ายวิศวะ — ใช้นับยอดสะสมอย่างเดียวพอ · ระบบอ่านได้ ' + opN0(srcPending.cum.n) + ' โครงการ · ' + opM(srcPending.cum.valueM)),
            el('div', { style: { marginTop: 8 } },
              opBtn(p, '✓ บันทึกเป็นยอดสะสม', () => applyCumulative(srcPending.cum), 'primary', busy || !editable)))
          : null,
        !srcPending.plan.ok
          ? el('div', { style: { background: 'rgba(192,57,43,.10)', border: '1px solid ' + p.bad, borderRadius: 10, padding: '10px 12px', fontSize: 12.5, lineHeight: 1.7 } },
            el('div', { style: { fontWeight: 700, color: p.bad, marginBottom: 4 } }, 'อ่านไฟล์นี้ไม่ได้ — จับคู่ไม่ครบ ' + srcPending.plan.missing.length + ' จุด (ยังไม่บันทึกอะไรทั้งนั้น)'),
            srcPending.plan.missing.map((mm, i) => el('div', { key: i }, '• ' + mm.label + (mm.want ? ' (หาชื่อ "' + mm.want + '" ไม่เจอ)' : ''))),
            el('div', { style: { marginTop: 6, color: p.sub } }, 'เลือกชีต/คอลัมน์ที่ถูกต้องด้านล่าง — ระบบจะจำไว้ใช้งวดถัดไปเอง'))
          : null,
        (srcPending.warn || []).length
          ? el('div', { style: { background: 'rgba(184,134,43,.12)', border: '1px solid ' + p.gold, borderRadius: 10, padding: '10px 12px', fontSize: 12.5, lineHeight: 1.7 } },
            el('div', { style: { fontWeight: 700, color: p.gold, marginBottom: 4 } }, 'ตรวจแล้วมีจุดน่าสงสัย'),
            srcPending.warn.map((w, i) => el('div', { key: i }, '• ' + w)))
          : null,
        srcStats && srcPending.plan.ok
          ? el('div', { style: { border: '1px solid ' + p.line, borderRadius: 10, padding: '10px 14px' } },
            el('div', { style: { fontSize: 12.5, fontWeight: 700, marginBottom: 6 } }, 'อ่านได้แบบนี้'),
            srcStats.map((r, i) => el('div', { key: i, style: { display: 'flex', gap: 10, fontSize: 12.5, padding: '2px 0' } },
              el('div', { style: { color: p.sub, minWidth: 150 } }, r[0]),
              el('div', { style: { fontWeight: 700, fontVariantNumeric: 'tabular-nums' } }, r[1]))))
          : null,
        el('div', null,
          el('a', { href: '#', onClick: e => { e.preventDefault(); setPending(Object.assign({}, srcPending, { showMap: !srcPending.showMap })); },
            style: { color: p.brand, fontSize: 12.5, fontWeight: 600 } },
            srcPending.showMap ? '▾ ซ่อนการจับคู่ชีต/คอลัมน์' : '▸ ตรวจ/แก้การจับคู่ชีตและคอลัมน์'),
          srcPending.showMap ? el('div', { style: { marginTop: 6 } }, srcMapUI) : null),
        el('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
          opBtn(p, '✓ ยืนยันบันทึก', applySource, 'primary', busy || !editable || !srcPending.plan.ok),
          opBtn(p, 'ยกเลิก', () => setPending(null)))));

    const cumPending = (pending && pending.mode === 'cum') ? pending : null;
    const cumCard = !cumPending ? null : opCard(p, 'ตรวจก่อนบันทึก · ไฟล์ Project Control ปีเก่า', cumPending.fileName,
      el('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
        cumPending.cum.sheets.length
          ? el('div', { style: { border: '1px solid ' + p.line, borderRadius: 10, padding: '10px 14px' } },
            el('div', { style: { fontSize: 12.5, fontWeight: 700, marginBottom: 6 } }, 'อ่านได้แบบนี้'),
            Object.keys(cumPending.cum.years).sort().map(y => el('div', { key: y, style: { display: 'flex', gap: 10, fontSize: 12.5, padding: '2px 0' } },
              el('div', { style: { color: p.sub, minWidth: 110 } }, 'ปีงบ 25' + y),
              el('div', { style: { fontWeight: 700, fontVariantNumeric: 'tabular-nums' } },
                opN0(cumPending.cum.years[y].n) + ' โครงการ · ' + opM(cumPending.cum.years[y].valueM)))),
            el('div', { style: { display: 'flex', gap: 10, fontSize: 13, padding: '6px 0 0', marginTop: 4, borderTop: '1px solid ' + p.line, fontWeight: 800 } },
              el('div', { style: { minWidth: 110 } }, 'รวมในไฟล์นี้'),
              el('div', { style: { fontVariantNumeric: 'tabular-nums' } }, opN0(cumPending.cum.n) + ' โครงการ · ' + opM(cumPending.cum.valueM))))
          : el('div', { style: { background: 'rgba(192,57,43,.10)', border: '1px solid ' + p.bad, borderRadius: 10, padding: '10px 12px', fontSize: 12.5, fontWeight: 600, color: p.bad } },
            'ไฟล์นี้ไม่มีชีต "Main all…" เลย — น่าจะไม่ใช่ไฟล์ Project Control'),
        el('div', { style: { fontSize: 12, color: p.sub } },
          'ยอดสะสมจะถูกรวมกับปีที่เคยอ่านไว้แล้ว (ปีเดียวกันจะถูกทับด้วยค่าใหม่)'),
        el('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
          opBtn(p, '✓ ยืนยันบันทึก', () => applyCumulative(cumPending.cum), 'primary', busy || !editable || !cumPending.cum.sheets.length),
          opBtn(p, 'ยกเลิก', () => setPending(null)))));

    // พรีวิวก่อนบันทึก (เทมเพลตกรอกมือ)
    // ★ การ์ดนี้เป็นของ "เทมเพลตกรอกมือ" เท่านั้น — ต้องกันโหมด source/cum ออกให้ครบ
    //   ไม่งั้นตอนอัปไฟล์ปีเก่า (mode 'cum') จะเข้ามาอ่าน pending.warn ที่ไม่มี → หน้าขาว
    const pendingCard = (pending && pending.mode !== 'source' && pending.mode !== 'cum') ? opCard(p, 'ตรวจก่อนบันทึก · ' + (pending.dept === 'sales' ? 'ฝ่ายขาย' : 'ฝ่ายเซอร์วิส'), pending.fileName,
      el('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
        pending.warn.length ? el('div', { style: { background: 'rgba(184,134,43,.12)', border: '1px solid ' + p.gold, borderRadius: 10, padding: '10px 12px', fontSize: 12.5, lineHeight: 1.7 } },
          pending.warn.map((w, i) => el('div', { key: i }, w))) : null,
        pending.diff.length
          ? el('div', { style: { overflowX: 'auto' } },
            el('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: 12.5 } },
              el('thead', null, el('tr', null,
                ['รายการ', 'ค่าเดิม', 'ค่าใหม่'].map((h, i) => el('th', { key: i, style: { textAlign: i ? 'right' : 'left', padding: '6px 8px', borderBottom: '1px solid ' + p.line, color: p.sub, fontWeight: 700, whiteSpace: 'nowrap' } }, h)))),
              el('tbody', null, pending.diff.map((d, i) => el('tr', { key: i },
                el('td', { style: { padding: '5px 8px', borderBottom: '1px solid ' + p.line } }, d.label),
                el('td', { style: { padding: '5px 8px', borderBottom: '1px solid ' + p.line, textAlign: 'right', color: p.sub, fontVariantNumeric: 'tabular-nums' } }, d.oldV),
                el('td', { style: { padding: '5px 8px', borderBottom: '1px solid ' + p.line, textAlign: 'right', fontWeight: 700, color: p.brand, fontVariantNumeric: 'tabular-nums' } }, d.newV))))))
          : el('div', { style: { fontSize: 12.5, color: p.sub } }, 'ไฟล์นี้ไม่มีค่าไหนต่างจากที่บันทึกไว้'),
        el('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
          opBtn(p, '✓ ยืนยันบันทึก', applyPending, 'primary', busy || !editable),
          opBtn(p, 'ยกเลิก', () => setPending(null))))) : null;

    // เช็กลิสต์
    const chkCard = opCard(p, 'สถานะความครบถ้วน', missing.length ? 'ยังขาด ' + missing.length + ' ช่อง' : 'กรอกครบทุกช่องแล้ว',
      missing.length
        ? el('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6 } },
          missing.map((x, i) => el('span', {
            key: i, style: { fontSize: 11.5, fontWeight: 600, padding: '3px 9px', borderRadius: 20, background: x.who === 'ฝ่ายขาย' ? 'rgba(184,134,43,.14)' : 'rgba(31,86,184,.10)', color: x.who === 'ฝ่ายขาย' ? p.gold : p.brand },
          }, x.label + ' · ' + x.who)))
        : el('div', { style: { fontSize: 13, color: p.good, fontWeight: 700 } }, '✓ พร้อมปิดงวดและพิมพ์'));

    // แถบปิดงวด/พิมพ์
    const actCard = opCard(p, null, null,
      el('div', { style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' } },
        el('div', { style: { fontSize: 13, fontWeight: 700, color: isFinal ? p.good : p.gold } },
          isFinal ? '● ปิดงวดแล้ว · ตัวเลขถูกแช่ไว้' + (period.meta.finalizedAt ? ' เมื่อ ' + opWhen(period.meta.finalizedAt) : '') : '● ฉบับร่าง · ตัวเลขฝั่งระบบอ่านสดจากทะเบียน'),
        el('div', { style: { marginLeft: 'auto', display: 'flex', gap: 8, flexWrap: 'wrap' } },
          !isFinal ? opBtn(p, '🔒 ปิดงวด (แช่ตัวเลข)', finalize, 'gold', !canFinal || busy) : opBtn(p, '↩ เปิดกลับเป็นร่าง', reopen, '', !canFinal || busy),
          opBtn(p, '🖨 พิมพ์ 1 หน้า', () => opPrint(model, logoUrl), 'primary'))));

    // พรีวิวหน้าจริง (renderer เดียวกับตอนพิมพ์)
    const previewCard = opCard(p, 'หน้าตัวอย่าง (เท่ากับที่พิมพ์ออกมาเป๊ะ)', 'A4 แนวนอน · 1 หน้า',
      el('div', { style: { overflowX: 'auto', background: '#e9edf4', borderRadius: 10, padding: 14 } },
        el('div', { style: { width: '297mm', transformOrigin: 'top left', margin: '0 auto', boxShadow: '0 6px 24px rgba(0,0,0,.16)' },
          dangerouslySetInnerHTML: { __html: opSheetHtml(model, logoUrl) } })));

    return el('div', null,
      el('style', null, OP_SHEET_CSS),
      head,
      el('div', { className: 'no-print', style: { display: 'flex', flexDirection: 'column', gap: 14, marginBottom: 16 } },
        fyBar, srcCard, srcCardPending, cumCard, tplCard, pendingCard, chkCard, actCard),
      previewCard);
  }

  window.InvOnePage = InvOnePage;
})();
