// Daily Report — app orchestration + PIN screen. Mounts to #root.
const { useState: useS, useEffect, useRef } = React;
const HD = window.HATAARA;

// Owner (admin) code. ONLY this exact code has full permission — including
// creating backdated (past-date) transactions. Every other code is staff.
const OWNER_CODE = '110-04-0';

// PIN → ผู้ใช้ (prototype only; production authentication belongs in the backend).
// The login field keeps only digits, so the owner types the 6 digits of the
// owner code (110-04-0 → "110040"); user.code carries the canonical code so
// isOwnerCode() can match it exactly. 000000 is a NORMAL STAFF code.
const PINS = {
  '110040': { name: 'ปอม', role: 'owner', code: OWNER_CODE },
  '000000': { name: 'พนักงาน', role: 'staff', code: '000000' },
  '111111': { name: 'พนักงาน', role: 'staff', code: '111111' },
};

// Map a Worker session (role + client) back onto the local user shape the app
// has already grown to expect (name, role, code). Owner keeps the canonical
// OWNER_CODE so `canCreateTransactionForDate` still passes for backdated entry;
// staff + maid get non-owner codes so past-date rules bite.
function userFromSession(session) {
  if (!session) return null;
  switch (session.role) {
    case 'owner':
      return { name: 'ปอม',    role: 'owner', code: OWNER_CODE, client: 'front' };
    case 'maid':
      return { name: 'แม่บ้าน', role: 'staff', code: '111111',  client: 'maid' };
    case 'staff':
    default:
      return { name: 'พนักงาน', role: 'staff', code: '000000',  client: 'front' };
  }
}

// YYYY-MM-DD for the API using local (Bangkok) date. curDate is a Date object
// held in local time, so we format its year/month/day directly instead of
// going through toISOString() which shifts to UTC and can slip the day.
function curDateToIsoDate(d) {
  return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}

/* ─────────────────────────────────────────────────────────────────────
   Central permission helpers — the SINGLE source of truth for role/date
   creation rules. Reused everywhere a transaction can be created; never
   duplicate these checks inline.
   ───────────────────────────────────────────────────────────────────── */
// Owner is ONLY the exact owner code. Any other value (incl. 000000) is staff.
const isOwnerCode = (code) => String(code) === OWNER_CODE;
// Compare by calendar day, ignoring time-of-day.
const startOfDay = (d) => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; };
// selectedDate is strictly before today (a past calendar day).
const isPastDate = (selectedDate) => startOfDay(selectedDate) < startOfDay(new Date());
// A new transaction may be created for selectedDate when the user is the
// owner, OR the selected date is today or in the future. Staff can never
// create a past-date (backdated) transaction.
const canCreateTransactionForDate = (code, selectedDate) =>
  isOwnerCode(code) || !isPastDate(selectedDate);
const PAST_DATE_STAFF_WARNING = 'รหัสพนักงานไม่สามารถสร้างรายการย้อนหลังได้';

const NAV = [
  { id: 'dashboard',    label: 'DASHBOARD',    short: 'Dash',   icon: '🏠' },
  { id: 'income',       label: 'TRANSACTIONS', short: 'Trans',  icon: '💰' },
  { id: 'housekeeping', label: 'HOUSEKEEPING', short: 'HK',     icon: '🛏' },
  { id: 'maintenance',  label: 'MAINTENANCE',  short: 'Maint',  icon: '🔧' },
  { id: 'consignment',  label: 'CONSIGNMENT',  short: 'Con',    icon: '📦' },
  { id: 'supply',       label: 'SUPPLY',       short: 'Sup',    icon: '🧻' },
];

// Which tabs each session role can reach. Maid is locked to a single page
// (HK), which also means the mobile bottom-bar renders as a one-cell strip.
function allowedTabIdsForUser(user) {
  if (!user) return NAV.map((n) => n.id);
  if (user.client === 'maid') return ['housekeeping'];
  return NAV.map((n) => n.id);
}

/* ---------------- PIN screen ---------------- */
function PinScreen({ onUnlock, summary }) {
  const [pin, setPin] = useS('');
  const [err, setErr] = useS(false);
  const [now, setNow] = useS(new Date());

  useEffect(() => {
    const t = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(t);
  }, []);

  const h = now.getHours();
  const greeting = h < 12 ? { msg: 'อรุณสวัสดิ์', sub: 'เริ่มบันทึกรอบวันนี้' }
    : h < 17 ? { msg: 'สวัสดีตอนบ่าย', sub: 'บันทึกรายการประจำวัน' }
    : h < 21 ? { msg: 'สวัสดีตอนเย็น', sub: 'ตรวจรายการก่อนปิดรอบ' }
    : { msg: 'สวัสดี', sub: 'ตรวจรายการประจำวัน' };

  const timeStr = now.toLocaleTimeString('th-TH', { hour: '2-digit', minute: '2-digit' });
  const dateStr = now.toLocaleDateString('th-TH', { weekday: 'long', day: 'numeric', month: 'long' });

  const handleChange = (e) => {
    const val = e.target.value.replace(/\D/g, '').slice(0, 6);
    setPin(val);
    if (err) setErr(false);
    if (val.length === 6) {
      const user = PINS[val];
      if (user) onUnlock(user, val);
      else { setErr(true); setTimeout(() => setPin(''), 400); }
    }
  };
  const pinUser = PINS[pin];

  return (
    <div className="pin-screen">
      <div className="pin-card">
        <div className="pin-product">Hataara Daily Report</div>
        <div className="pin-clock">{timeStr}</div>
        <div className="pin-date-str">{dateStr}</div>
        <div className="pin-greeting">{greeting.msg}</div>
        <div className="pin-instruction">กรอกรหัส 6 หลัก</div>
        <div className="pin6-wrap">
          {[0,1,2,3,4,5].map((i) => (
            <div key={i} className={'pin6-box' + (err ? ' err' : pin.length > i ? ' filled' : pin.length === i ? ' active' : '')}>{pin[i] ? '●' : ''}</div>
          ))}
          <input className="pin6-hidden" type="tel" inputMode="numeric" pattern="[0-9]*"
            value={pin} autoFocus maxLength={6} onChange={handleChange} />
        </div>
        <div className="pin-user-preview">{pinUser ? `${pinUser.name} · ${pinUser.role}` : 'ผู้ใช้และสิทธิ์จะแสดงหลังกรอกรหัส'}</div>
        {err && <div className="pin-err">รหัสไม่ถูกต้อง กรุณาลองใหม่</div>}
        {!err && <div className="pin-err"></div>}
        <div className="prelogin-summary" aria-label="สรุปห้องแบบอ่านอย่างเดียว">
          <div className="pre-summary-card sold two-layer"><div className="psc-inner"><span className="z">{summary.sold}</span><small>ขายแล้ว</small></div></div>
          <div className="pre-summary-card stayover"><span className="z">{summary.stayover}</span><small>อยู่ต่อ</small></div>
          <div className="pre-summary-card available"><span className="z">{summary.available}</span><small>ว่าง</small></div>
          <div className="pre-summary-card pending-inv"><span className="z">{summary.pendingInv}</span><small>รอ INV</small></div>
          <div className="pre-summary-card broken"><span className="z">{summary.broken}</span><small>เสีย</small></div>
        </div>
      </div>
    </div>
  );
}

/* ---------------- Dashboard page ---------------- */
function DashboardPage({ invoices, noinvoices, allBookings, expenses, dataDates, occInv, occNoinv, times, setTimes, invSummary, noinvSummary, paymentReceivedTotal, cashTotal, expTotal, sendTotal, dateStr, shiftDay, shiftMonth, selectDate, goToday, locked, canDelete, userRole, openRoomAction, openEdit, maint, openMaint, roomSnapshot, onAddExpense }) {
  const [calendarOpen, setCalendarOpen] = useS(false);
  // Map every room to the active booking. A linked stayover with an outstanding
  // amount also makes the current room card pending, even before that stay starts.
  const txByRoom = {};
  const activeRows = [
    ...occInv.map((r) => ({ ...r, _kind: 'inv', _idx: r._srcIdx })),
    ...occNoinv.map((r) => ({ ...r, _kind: 'noinv', _idx: r._srcIdx })),
  ];
  activeRows.forEach((r) => {
    const k = String(r.room);
    if (!r.room) return;
    const previous = txByRoom[k];
    if (!previous || parseThaiDate(r.ci || r.date) > parseThaiDate(previous.ci || previous.date)) txByRoom[k] = r;
  });
  const allBookingRows = allBookings || [
    ...invoices.map((r) => ({ ...r, _kind: 'inv' })),
    ...noinvoices.map((r) => ({ ...r, _kind: 'noinv' })),
  ];
  // displayTxByRoom carries the chain-aggregated pendingAmount/paymentStatus
  // used ONLY for the room card's own colour/amount display. txByRoom itself
  // (used for editing — see openEdit below) is left holding each room's real,
  // un-mutated booking record. Editing a booking must never inherit another
  // record's totals from the chain-display aggregation.
  const displayTxByRoom = { ...txByRoom };
  Object.keys(displayTxByRoom).forEach((room) => {
    const current = displayTxByRoom[room];
    const chainId = resolveChainId(current, allBookingRows);
    // Sum pendingAmount across every non-voided booking in the same stay chain.
    const chainPendingTotal = allBookingRows
      .filter((r) => String(r.room) === room && !r._voided && !r.isVoided
        && resolveChainId(r, allBookingRows) === chainId)
      .reduce((sum, r) => sum + Number(r.pendingAmount || 0), 0);
    if (chainPendingTotal > 0) {
      const chainRecvTotal = allBookingRows
        .filter((r) => String(r.room) === room && !r._voided && !r.isVoided
          && resolveChainId(r, allBookingRows) === chainId)
        .reduce((sum, r) => sum + Number(r.recv || 0), 0);
      displayTxByRoom[room] = {
        ...current,
        paymentStatus: chainRecvTotal > 0 ? 'partial_deposit' : 'pending_payment',
        pendingAmount: chainPendingTotal,
      };
    }
  });
  // Payment Group aggregation — compute group totals from all occupied rooms,
  // then apply shared status colour + primary/member display amounts.
  // Only rooms currently in displayTxByRoom (occupied today) are considered.
  const pgTotals = {};
  Object.entries(displayTxByRoom).forEach(([room, rec]) => {
    if (!rec || !rec.paymentGroupId) return;
    const gid = String(rec.paymentGroupId);
    if (!pgTotals[gid]) pgTotals[gid] = { paid: 0, outstanding: 0, rooms: [] };
    pgTotals[gid].paid += Number(rec.recv || 0);
    pgTotals[gid].outstanding += Number(rec.pendingAmount || 0);
    pgTotals[gid].rooms.push(room);
  });
  const displayTxByRoomFinal = { ...displayTxByRoom };
  Object.values(pgTotals).forEach((g) => {
    if (g.rooms.length < 2) return; // single-room group: no change needed
    const groupPaid = g.paid;
    const groupOutstanding = g.outstanding;
    const groupStatus = groupOutstanding > 0
      ? (groupPaid > 0 ? 'partial_deposit' : 'pending_payment')
      : 'received';
    g.rooms.forEach((room) => {
      const rec = displayTxByRoomFinal[room];
      if (!rec) return;
      const isPrimary = !!rec.paymentGroupIsPrimary;
      displayTxByRoomFinal[room] = {
        ...rec,
        paymentStatus: groupStatus,
        recv: isPrimary ? groupPaid : rec.recv,
        pendingAmount: isPrimary ? groupOutstanding : rec.pendingAmount,
        _pgMember: !isPrimary,
      };
    });
  });

  const allTx     = [...invoices, ...noinvoices];
  const totalRecv = Number(paymentReceivedTotal || 0);
  const allRooms  = Object.values(HD.ROOMS_BY_FLOOR).flat();
  const totalRooms = allRooms.length;
  const soldCount   = allRooms.filter((r) => txByRoom[String(r)]).length;
  const brokenCount = allRooms.filter((r) => maint[String(r)] && maint[String(r)].type === 'broken').length;
  const freeCount   = allRooms.filter((r) => maint[String(r)] && maint[String(r)].type === 'free').length;
  // Operational counts for the print handover summary (display-only, no logic change):
  //  - stayover: guests whose C/I is before the report date (already staying)
  //  - pending INV: today's invoices still waiting for an invoice number
  const stayoverCount = [...occInv, ...occNoinv]
    .filter((r) => !r._addon && !r._settleOf && r.ci && parseThaiDate(r.ci) < parseThaiDate(dateStr)).length;
  const pendingInvCount = invoices.filter((r) => r.invoiceStatus === 'pending' && !r._addon).length;
  const canAct = !locked || canDelete;
  const formatTimeInput = (raw) => {
    let digits = String(raw).replace(/[^0-9]/g, '').slice(0, 4);
    if (digits.length <= 2) return digits;
    return digits.slice(0, 2) + ':' + digits.slice(2);
  };
  const onTime = (k, v) => setTimes((t) => ({ ...t, [k]: formatTimeInput(v) }));

  return (
    <div className="page dash">
      {/* Dedicated print document. It reuses the dashboard's already-derived
          values but is completely separate from the live dashboard DOM, so
          print CSS never has to reshape or expose operational controls. */}
      <section className="print-report" aria-label="รายงาน Daily Report สำหรับพิมพ์">
        <header className="print-report-head">
          <div>
            <h1>Hataara Daily Report</h1>
            <div className="print-report-date">วันที่ {dateStr}</div>
          </div>
          <div className="print-shift-times">
            <span>เปิดรอบ <strong>{times.start || '—'}</strong></span>
            <span>ปิดรอบ <strong>{times.end || '—'}</strong></span>
          </div>
        </header>

        <div className="print-room-summary">
          <div><span>ห้องขาย</span><strong>{soldCount}</strong></div>
          <div><span>ห้องฟรี</span><strong>{freeCount}</strong></div>
          <div><span>ห้องเสีย</span><strong>{brokenCount}</strong></div>
          <div><span>อยู่ต่อ</span><strong>{stayoverCount}</strong></div>
          <div><span>รอ INV</span><strong>{pendingInvCount}</strong></div>
        </div>

        <section className="print-payment-summary">
          <h2>สรุปการรับเงิน</h2>
          <div className="print-payment-columns">
            <div className="print-payment-column">
              <h3>Invoice</h3>
              <div><span>CASH</span><strong>{fmt(invSummary.cash)}</strong></div>
              <div><span>QR</span><strong>{fmt(invSummary.qr)}</strong></div>
              <div><span>EDC</span><strong>{fmt(invSummary.edc)}</strong></div>
              <div><span>KEY-IN</span><strong>{fmt(invSummary.keyin)}</strong></div>
            </div>
            <div className="print-payment-column">
              <h3>No Invoice</h3>
              <div><span>CASH</span><strong>{fmt(noinvSummary.cash)}</strong></div>
              <div><span>QR</span><strong>{fmt(noinvSummary.qr)}</strong></div>
              <div><span>BBL</span><strong>{fmt(noinvSummary.bbl)}</strong></div>
              <div><span>AGP</span><strong>{fmt(noinvSummary.agp)}</strong></div>
            </div>
          </div>
          <div className="print-total-line">
            <span>ยอดรับทั้งหมด</span><strong>{fmt(totalRecv)}</strong>
          </div>
        </section>

        <div className="print-total-line print-cash-line">
          <span>เงินสด</span><strong>{fmt(cashTotal)}</strong>
        </div>
        <section className="print-expense-list" aria-label="รายการรายจ่าย">
          <h2>รายการรายจ่าย</h2>
          {expenses.length ? expenses.map((row, i) => (
            <div className="print-expense-row" key={row._id || i}>
              <span>
                <strong>{row.cat || 'รายจ่าย'}</strong>
                <small>{row.detail || 'ไม่มีรายละเอียด'}</small>
              </span>
              <b>{fmt(row.amount)}</b>
            </div>
          )) : (
            <div className="print-expense-row print-expense-empty">
              <span>ยังไม่มีรายจ่าย</span>
              <b>{fmt(0)}</b>
            </div>
          )}
        </section>
        <div className="print-expense-total">
          <span>รวมรายจ่าย</span><strong>{fmt(expTotal)}</strong>
        </div>
        <div className="print-remittance">
          <span>ส่งยอด</span><strong>{fmt(sendTotal)}</strong>
        </div>
      </section>

      {/* ── 2-PANEL BODY ── */}
      <div className="dash-body">
        {/* LEFT — full room board: every room, click to add / edit / set status */}
        <div className="dash-left">
          <div className="tx-grid">
            {(() => {
              // Closed-history rule: a locked date with a saved snapshot renders
              // from that snapshot, not from a live recomputation. Later payments
              // change Transactions and the CURRENT payment round only — they
              // must never recolour a previously closed Dashboard date. A locked
              // date with no snapshot (e.g. locked before this feature existed)
              // falls back to live computation so old data never crashes.
              const liveStates = computeRoomCardStates({ allRooms, txByRoom: displayTxByRoomFinal, maint, allBookingRows, selectedDateStr: dateStr });
              const states = (locked && roomSnapshot) ? roomSnapshot : liveStates;
              return allRooms.map((room) => {
                const k = String(room);
                const entry = states[k] || liveStates[k];
                const { status, tx, stayProgress, conditionDuringStay } = entry;
                const booking = txByRoom[k]; // raw record, NOT the chain-aggregated display object
                const st = maint[k];
                const canAct = !locked || canDelete;
                const onClick = canAct && !(booking && booking._privateHidden && userRole !== 'owner')
                  ? status === 'available'
                    ? () => openRoomAction(room, null, status)
                    : st
                      ? () => openMaint(room)
                      : () => openEdit(booking._kind, booking, booking._idx)
                  : undefined;
                return <RoomCard key={room} room={room} tx={tx} status={status} stayProgress={stayProgress} conditionDuringStay={conditionDuringStay} onClick={onClick} />;
              });
            })()}
          </div>
        </div>

        {/* RIGHT — vertical stack */}
        <div className="dash-right">
          {/* 1. Calendar — date is the primary context, shown first */}
          <MiniCalendar date={dateStr} dataDates={dataDates} onSelect={selectDate} onPrevMonth={() => shiftMonth(-1)} onNextMonth={() => shiftMonth(1)} onToday={goToday} />

          {/* 2. Shift card — sub-detail of the selected date */}
          <ShiftCard start={times.start} end={times.end} onTime={onTime} locked={locked} />

          {/* 3. Room KPI */}
          <RoomSummary sold={soldCount} free={freeCount} broken={brokenCount} />

          {/* 4. Financial summary */}
          <div className="dash-fin-card">
            <div className="dash-fin-title">สรุปการรับเงิน</div>
            <SummaryCards inv={invSummary} noinv={noinvSummary} />
            <div className="dash-fin-divider" />
            <div className="dash-fin-row">
              <span className="dash-fin-lbl">ยอดรับทั้งหมด</span>
              <span className="dash-fin-amt z">{fmt(totalRecv)}</span>
            </div>
            <div className="dash-fin-row cash-row">
              <span className="dash-fin-lbl">เงินสด</span>
              <span className="dash-fin-amt z">{fmt(cashTotal)}</span>
            </div>
          </div>

          {/* 5. Expenses */}
          <ExpenseBreakdown rows={expenses} total={expTotal} onAdd={onAddExpense} locked={locked} onEditRow={(r, i) => (!locked || canDelete) ? openEdit('exp', r, r._srcIdx ?? i) : null} />

          {/* 6. Send total */}
          <div className={'dash-send-bar' + (sendTotal < 0 ? ' negative' : '')}>
            <span className="dash-send-lbl">↗ ส่งยอดวันนี้</span>
            <span className="dash-send-amt z">{fmt(sendTotal)}</span>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ================================================================
   CONSIGNMENT — ฝากขาย
   ================================================================ */
const CON_PRODUCTS_KEY = 'hataara_con_products';
const CON_SALES_KEY = 'hataara_con_sales';
const CON_STOCK_LOG_KEY = 'hataara_con_stocklog';
const CON_CATS = ['beverage','food','snack','other'];
const CON_SEED = [
  { name: 'น้ำเปล่า 600ml', category: 'beverage', unit_price: 20, cost_price: 8, stock: 24, low_stock: 5, unit: 'ขวด' },
  { name: 'กาแฟดำกระป๋อง', category: 'beverage', unit_price: 25, cost_price: 12, stock: 12, low_stock: 3, unit: 'กระป๋อง' },
  { name: 'โค้ก 330ml', category: 'beverage', unit_price: 25, cost_price: 12, stock: 12, low_stock: 3, unit: 'กระป๋อง' },
  { name: 'สไปรท์ 330ml', category: 'beverage', unit_price: 25, cost_price: 12, stock: 12, low_stock: 3, unit: 'กระป๋อง' },
  { name: 'เบียร์ช้าง', category: 'beverage', unit_price: 65, cost_price: 38, stock: 12, low_stock: 3, unit: 'ขวด' },
  { name: 'เบียร์สิงห์', category: 'beverage', unit_price: 65, cost_price: 40, stock: 12, low_stock: 3, unit: 'ขวด' },
  { name: 'มาม่าถ้วย', category: 'food', unit_price: 20, cost_price: 10, stock: 20, low_stock: 5, unit: 'ถ้วย' },
  { name: 'ขนมปังปิ้ง', category: 'food', unit_price: 40, cost_price: 15, stock: 10, low_stock: 3, unit: 'ชุด' },
  { name: 'ไข่ต้ม', category: 'food', unit_price: 15, cost_price: 5, stock: 10, low_stock: 5, unit: 'ฟอง' },
  { name: 'สแน็คมันฝรั่ง', category: 'snack', unit_price: 30, cost_price: 15, stock: 10, low_stock: 3, unit: 'ซอง' },
];

function conUuid() { return 'con-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); }
function conNow() { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0') + ' ' + String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0') + ':' + String(d.getSeconds()).padStart(2,'0'); }
function conToday() { return conNow().slice(0,10); }
function conTime() { return conNow().slice(11,16); }

function loadConProducts() {
  try { const s = localStorage.getItem(CON_PRODUCTS_KEY); if (s) return JSON.parse(s); } catch {}
  const seeded = CON_SEED.map((p,i) => ({ ...p, product_id: 'seed-' + i, active: true, created_at: conNow(), updated_at: conNow() }));
  localStorage.setItem(CON_PRODUCTS_KEY, JSON.stringify(seeded));
  return seeded;
}
function saveConProducts(p) { localStorage.setItem(CON_PRODUCTS_KEY, JSON.stringify(p)); }
function loadConSales() { try { return JSON.parse(localStorage.getItem(CON_SALES_KEY) || '[]'); } catch { return []; } }
function saveConSales(s) { localStorage.setItem(CON_SALES_KEY, JSON.stringify(s)); }
function loadConStockLog() { try { return JSON.parse(localStorage.getItem(CON_STOCK_LOG_KEY) || '[]'); } catch { return []; } }
function saveConStockLog(l) { localStorage.setItem(CON_STOCK_LOG_KEY, JSON.stringify(l)); }
function stockClass(stock, low) { return stock <= 0 ? 'stock-out' : stock <= low ? 'stock-low' : 'stock-normal'; }

function ConsignmentPage({ userCode, userName }) {
  const isOwner = isOwnerCode(userCode);
  const [products, setProducts] = useS(loadConProducts);
  const [sales, setSales] = useS(loadConSales);
  const [stockLog, setStockLog] = useS(loadConStockLog);
  const [cart, setCart] = useS([]);
  const [payment, setPayment] = useS('cash');
  const [feedback, setFeedback] = useS('');
  const [showHistory, setShowHistory] = useS(false);
  const [editProduct, setEditProduct] = useS(null);
  const [restockProd, setRestockProd] = useS(null);
  const [restockQty, setRestockQty] = useS('');

  const saveAll = (prods, sls, logs) => {
    setProducts(prods); saveConProducts(prods);
    if (sls !== undefined) { setSales(sls); saveConSales(sls); }
    if (logs !== undefined) { setStockLog(logs); saveConStockLog(logs); }
  };

  const activeProducts = products.filter(p => p.active);
  const cartTotal = cart.reduce((s, c) => s + c.qty * c.unit_price, 0);

  const addToCart = (prod) => {
    if (prod.stock <= 0) return;
    setCart(prev => {
      const idx = prev.findIndex(c => c.product_id === prod.product_id);
      if (idx >= 0) {
        const next = [...prev];
        if (next[idx].qty < prod.stock) next[idx] = { ...next[idx], qty: next[idx].qty + 1 };
        return next;
      }
      return [...prev, { product_id: prod.product_id, product_name: prod.name, unit_price: prod.unit_price, qty: 1 }];
    });
  };

  const updateCartQty = (pid, delta) => {
    const prod = products.find(p => p.product_id === pid);
    setCart(prev => prev.map(c => {
      if (c.product_id !== pid) return c;
      const nq = Math.max(0, Math.min(c.qty + delta, prod ? prod.stock : c.qty));
      return { ...c, qty: nq };
    }).filter(c => c.qty > 0));
  };

  const removeFromCart = (pid) => setCart(prev => prev.filter(c => c.product_id !== pid));

  const confirmSale = () => {
    if (cart.length === 0) return;
    let prods = [...products];
    let newSales = [...sales];
    let newLog = [...stockLog];
    const ts = conNow();
    const d = conToday();
    const t = conTime();
    let totalAmount = 0;

    for (const item of cart) {
      const pi = prods.findIndex(p => p.product_id === item.product_id);
      if (pi === -1) continue;
      const prod = prods[pi];
      if (prod.stock < item.qty) { setFeedback('สต็อกไม่พอ: ' + prod.name); return; }
      const saleId = conUuid();
      const total = item.qty * item.unit_price;
      totalAmount += total;
      newSales.push({ sale_id: saleId, date: d, time: t, product_id: item.product_id, product_name: item.product_name, qty: item.qty, unit_price: item.unit_price, total, payment, seller: userName, voided: false, voided_by: null, voided_at: null, note: '', created_at: ts });
      const before = prod.stock;
      prods[pi] = { ...prod, stock: before - item.qty, updated_at: ts };
      newLog.push({ log_id: conUuid(), date: d, product_id: item.product_id, product_name: item.product_name, direction: 'out', qty: item.qty, reason: 'sale', ref_id: saleId, stock_before: before, stock_after: before - item.qty, actor: userName, note: '', created_at: ts });
    }

    saveAll(prods, newSales, newLog);
    setCart([]);
    setFeedback('บันทึกแล้ว ฿' + totalAmount.toLocaleString());
    setTimeout(() => setFeedback(''), 3000);
  };

  const voidSale = (saleId) => {
    if (!isOwner) return;
    const si = sales.findIndex(s => s.sale_id === saleId);
    if (si === -1 || sales[si].voided) return;
    const sale = sales[si];
    const ts = conNow();
    let prods = [...products];
    let newLog = [...stockLog];
    const pi = prods.findIndex(p => p.product_id === sale.product_id);
    if (pi !== -1) {
      const before = prods[pi].stock;
      prods[pi] = { ...prods[pi], stock: before + sale.qty, updated_at: ts };
      newLog.push({ log_id: conUuid(), date: conToday(), product_id: sale.product_id, product_name: sale.product_name, direction: 'in', qty: sale.qty, reason: 'return', ref_id: saleId, stock_before: before, stock_after: before + sale.qty, actor: userName, note: 'void sale', created_at: ts });
    }
    const newSales = [...sales];
    newSales[si] = { ...sale, voided: true, voided_by: userName, voided_at: ts };
    saveAll(prods, newSales, newLog);
  };

  const doRestock = (productId, qty) => {
    if (!qty || qty <= 0) return;
    const ts = conNow();
    let prods = [...products];
    let newLog = [...stockLog];
    const pi = prods.findIndex(p => p.product_id === productId);
    if (pi === -1) return;
    const before = prods[pi].stock;
    prods[pi] = { ...prods[pi], stock: before + qty, updated_at: ts };
    newLog.push({ log_id: conUuid(), date: conToday(), product_id: productId, product_name: prods[pi].name, direction: 'in', qty, reason: 'restock', ref_id: '', stock_before: before, stock_after: before + qty, actor: userName, note: '', created_at: ts });
    saveAll(prods, undefined, newLog);
  };

  // Owner restock straight from the main grid — opens an in-app modal.
  const openRestock = (prod) => { setRestockProd(prod); setRestockQty(''); };
  const submitRestock = () => {
    const qty = parseInt(restockQty, 10);
    if (!qty || qty <= 0) return;
    doRestock(restockProd.product_id, qty);
    setRestockProd(null);
    setRestockQty('');
  };

  const saveProduct = (prod) => {
    const ts = conNow();
    let prods = [...products];
    if (prod.product_id) {
      const pi = prods.findIndex(p => p.product_id === prod.product_id);
      if (pi !== -1) prods[pi] = { ...prods[pi], ...prod, updated_at: ts };
    } else {
      prods.push({ ...prod, product_id: conUuid(), active: true, created_at: ts, updated_at: ts });
    }
    saveAll(prods);
    setEditProduct(null);
  };

  const todaySales = sales.filter(s => s.date === conToday() && !s.voided);
  const todayTotal = todaySales.reduce((s, r) => s + r.total, 0);
  const todayCashTotal = todaySales.filter(s => s.payment === 'cash').reduce((s, r) => s + r.total, 0);
  const todayQrTotal = todaySales.filter(s => s.payment === 'qr').reduce((s, r) => s + r.total, 0);

  return (
    <div className="page dash con-dash-page">
      <div className="dash-body">
        <div className="dash-left">
        <div className="con-grid">
          {activeProducts.map(p => (
            <div key={p.product_id} className="goods-card-wrap">
              <div className={'con-card' + (cart.some(c => c.product_id === p.product_id) ? ' con-card-selected' : '') + (p.stock <= 0 ? ' con-card-disabled' : '')} onClick={() => addToCart(p)}>
                {isOwner && (
                  <div className="goods-owner-tools" onClick={e => e.stopPropagation()}>
                    <button className="goods-tool-btn" title="เติมสต็อก" onClick={() => openRestock(p)}>＋</button>
                    <button className="goods-tool-btn" title="แก้ไข" onClick={() => setEditProduct({...p})}>✎</button>
                  </div>
                )}
                <div className="goods-card-top">
                  <div className="con-card-name">{p.name}</div>
                </div>
                <div className="goods-card-bottom">
                  <div className="con-card-price">฿{p.unit_price}</div>
                  <div className="con-card-sub">
                    <span className={'con-card-stock ' + stockClass(p.stock, p.low_stock)}>
                      {p.stock <= 0 ? 'หมด' : `${p.stock} ${p.unit || ''}`}
                    </span>
                    {isOwner && <span className="con-card-cost">· ทุน ฿{p.cost_price}</span>}
                  </div>
                </div>
              </div>
            </div>
          ))}
          {isOwner && (
            <div className="goods-add-card" onClick={() => setEditProduct({ name:'', category:'beverage', unit_price:0, cost_price:0, stock:0, low_stock:5, unit:'ชิ้น' })}>
              <span className="con-add-icon">+</span>
              <span className="con-add-label">เพิ่มสินค้า</span>
            </div>
          )}
        </div>
        </div>
        <div className="dash-right con-right-panel">
        <div
          className={'con-sales-summary' + (isOwner ? ' clickable' : '')}
          onClick={() => { if (isOwner) setShowHistory(true); }}
        >
          <div className="con-summary-head">
            <h3 className="con-summary-title">สรุปรายการขาย</h3>
            <span className="con-summary-count">
              วันนี้ · {todaySales.length} รายการ{isOwner ? ' →' : ''}
            </span>
          </div>
          <div className="con-summary-rows">
            <div className="con-summary-row">
              <span className="con-summary-label">เงินสด</span>
              <span className="con-summary-value">฿{todayCashTotal.toLocaleString()}</span>
            </div>
            <div className="con-summary-row">
              <span className="con-summary-label">QR</span>
              <span className="con-summary-value">฿{todayQrTotal.toLocaleString()}</span>
            </div>
            <div className="con-summary-row con-summary-total-row">
              <span className="con-summary-label">รวม</span>
              <span className="con-summary-value">฿{todayTotal.toLocaleString()}</span>
            </div>
          </div>
        </div>

        <div className="con-cart-section">
          <h3 className="con-entry-title">รายการที่จะขาย</h3>
          {cart.length === 0 && <div className="con-empty">กดเลือกสินค้าจากด้านซ้าย</div>}
          {cart.map(c => (
            <div key={c.product_id} className="con-cart-item">
              <div className="con-cart-name">{c.product_name}</div>
              <div className="con-cart-qty">
                <button onClick={() => updateCartQty(c.product_id, -1)}>−</button>
                <span>{c.qty}</span>
                <button onClick={() => updateCartQty(c.product_id, 1)}>+</button>
              </div>
              <div className="con-cart-subtotal">฿{(c.qty * c.unit_price).toLocaleString()}</div>
              <button className="con-cart-remove" onClick={() => removeFromCart(c.product_id)}>×</button>
            </div>
          ))}
          {cart.length > 0 && (
            <React.Fragment>
              <div className="con-cart-total">รวม ฿{cartTotal.toLocaleString()}</div>
              <div className="con-pay-btns">
                <button className={payment === 'cash' ? 'active' : ''} onClick={() => setPayment('cash')}>เงินสด</button>
                <button className={payment === 'qr' ? 'active' : ''} onClick={() => setPayment('qr')}>QR</button>
              </div>
              <button className="con-confirm-btn" onClick={confirmSale}>ยืนยัน</button>
            </React.Fragment>
          )}
          {feedback && <div className="con-feedback">{feedback}</div>}
        </div>
      </div>
      </div>

      {/* ── Edit Product popup ── */}
      {editProduct && isOwner && (
        <div className="scrim" onMouseDown={() => setEditProduct(null)}>
          <div className="popup con-edit-popup" onMouseDown={e => e.stopPropagation()}>
            <div className="popup-head"><h3>{editProduct.product_id ? 'แก้ไขสินค้า' : 'เพิ่มสินค้า'}</h3><button className="popup-x" onClick={() => setEditProduct(null)}>×</button></div>
            <label>ชื่อ<input value={editProduct.name} onChange={e => setEditProduct({...editProduct, name: e.target.value})} /></label>
            <label>หมวด<select value={editProduct.category} onChange={e => setEditProduct({...editProduct, category: e.target.value})}>{CON_CATS.map(c=><option key={c} value={c}>{c}</option>)}</select></label>
            <label>ราคาขาย<input type="number" value={editProduct.unit_price} onChange={e => setEditProduct({...editProduct, unit_price: Number(e.target.value)})} /></label>
            <label>ต้นทุน<input type="number" value={editProduct.cost_price} onChange={e => setEditProduct({...editProduct, cost_price: Number(e.target.value)})} /></label>
            <label>สต็อก<input type="number" value={editProduct.stock} onChange={e => setEditProduct({...editProduct, stock: Number(e.target.value)})} /></label>
            <label>แจ้งเตือนเมื่อเหลือ<input type="number" value={editProduct.low_stock} onChange={e => setEditProduct({...editProduct, low_stock: Number(e.target.value)})} /></label>
            <label>หน่วย<input value={editProduct.unit} onChange={e => setEditProduct({...editProduct, unit: e.target.value})} /></label>
            {editProduct.product_id && <label>เปิดใช้<select value={String(editProduct.active)} onChange={e => setEditProduct({...editProduct, active: e.target.value==='true'})}><option value="true">ใช่</option><option value="false">ไม่</option></select></label>}
            <button className="con-save-btn" onClick={() => saveProduct(editProduct)}>บันทึก</button>
          </div>
        </div>
      )}

      {/* ── Restock popup (owner) ── */}
      {restockProd && isOwner && (
        <div className="scrim" onMouseDown={() => setRestockProd(null)}>
          <div className="popup goods-restock-popup" onMouseDown={e => e.stopPropagation()}>
            <div className="popup-head"><h3>เติมสต็อก</h3><button className="popup-x" onClick={() => setRestockProd(null)}>×</button></div>
            <div className="goods-restock-body">
              <div className="goods-restock-name">{restockProd.name}</div>
              <div className="goods-restock-current">คงเหลือ {restockProd.stock} {restockProd.unit || ''}</div>
              <label>จำนวนที่เติม
                <input type="number" min="1" autoFocus value={restockQty} onChange={e => setRestockQty(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') submitRestock(); }} placeholder="0" />
              </label>
              <button className="goods-restock-btn" onClick={submitRestock}>ยืนยัน</button>
            </div>
          </div>
        </div>
      )}

      {/* ── Sales History popup ── */}
      {showHistory && isOwner && (
        <div className="scrim" onMouseDown={() => setShowHistory(false)}>
          <div className="popup con-history-popup" onMouseDown={e => e.stopPropagation()}>
            <div className="popup-head"><h3>ประวัติการขาย</h3><button className="popup-x" onClick={() => setShowHistory(false)}>×</button></div>
            <div className="con-history-list">
              {sales.slice().reverse().slice(0, 50).map(s => (
                <div key={s.sale_id} className={'con-history-row' + (s.voided ? ' voided' : '')}>
                  <span>{s.date} {s.time}</span>
                  <span>{s.product_name} ×{s.qty}</span>
                  <span>฿{s.total}</span>
                  <span>{s.payment}</span>
                  <span>{s.seller}</span>
                  {s.voided ? <span className="voided-label">VOID</span> : <button onClick={() => { if(confirm('Void รายการนี้?')) voidSale(s.sale_id); }}>Void</button>}
                </div>
              ))}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

/* ================================================================
   SUPPLY — internal hotel supply request flow
   Staff requests → owner delivers → stock decrements only on delivery.
   ================================================================ */
// v2 keys — schema changed (usage_tag, min_stock_qty, storage_location,
// requestable), so we branch off from the old immediate-withdrawal store
// instead of migrating in place.
const SUP_ITEMS_KEY     = 'hataara_sup_items_v2';
const SUP_REQUESTS_KEY  = 'hataara_sup_requests_v1';   // active + history combined; filter by status
const SUP_STOCK_LOG_KEY = 'hataara_sup_stocklog_v1';   // append-only audit trail: every in/out on stock_qty

const SUP_CATS = ['ครัว','ทำความสะอาด','อะไหล่'];
const SUP_STATUS = { REQUESTED: 'REQUESTED', DELIVERED: 'DELIVERED', CANCELLED: 'CANCELLED' };
// Reasons a stock_qty change was recorded. `delivery` = confirmed request (out),
// `restock` = owner topped up (in), `adjust` = physical stocktake corrected the count.
const SUP_LOG_REASON = { DELIVERY: 'delivery', RESTOCK: 'restock', ADJUST: 'adjust' };
const SUP_LOG_REASON_LABEL = { delivery: 'จัดส่ง', restock: 'เติมสต็อก', adjust: 'ปรับสต็อก' };
// Use-location options. 'ห้องพัก' requires a room number; the rest just use the type as the value.
const SUP_USE_LOCATIONS = ['ห้องพัก','ครัว','ซักผ้า','พื้นที่ส่วนกลาง','ซ่อมบำรุง','อื่น ๆ'];

// Physically-distinct items are kept separate (no merging of AAA/AA batteries,
// no merging of kitchen tissue with room-side toilet paper, etc.).
const SUP_SEED = [
  // ครัว
  { name: 'น้ำตาล',              category: 'ครัว', usage_tag: 'ครัว',   unit: 'กก.',    stock_qty: 5, min_stock_qty: 2, storage_location: 'ครัว' },
  { name: 'กาแฟ',                category: 'ครัว', usage_tag: 'ครัว',   unit: 'ถุง',   stock_qty: 5, min_stock_qty: 2, storage_location: 'ครัว' },
  { name: 'คอฟฟี่เมด',            category: 'ครัว', usage_tag: 'ครัว',   unit: 'ถุง',   stock_qty: 3, min_stock_qty: 1, storage_location: 'ครัว' },
  { name: 'ซุปฟ้าไทย',            category: 'ครัว', usage_tag: 'ครัว',   unit: 'ขวด',   stock_qty: 3, min_stock_qty: 1, storage_location: 'ครัว' },
  { name: 'น้ำมันพืช',            category: 'ครัว', usage_tag: 'ครัว',   unit: 'ขวด',   stock_qty: 2, min_stock_qty: 1, storage_location: 'ครัว' },
  { name: '3in1',                category: 'ครัว', usage_tag: 'ครัว',   unit: 'กล่อง', stock_qty: 3, min_stock_qty: 1, storage_location: 'ครัว' },
  { name: 'กระดาษทิชชู่ครัว',      category: 'ครัว', usage_tag: 'ครัว',   unit: 'ม้วน',  stock_qty: 10, min_stock_qty: 3, storage_location: 'ครัว' },
  // ทำความสะอาด
  { name: 'แฟ้บ',                 category: 'ทำความสะอาด', usage_tag: 'ซักผ้า',     unit: 'ถุง', stock_qty: 5, min_stock_qty: 2, storage_location: 'ห้องเก็บของ' },
  { name: 'ไฮเตอร์',              category: 'ทำความสะอาด', usage_tag: 'ซักผ้า',     unit: 'ขวด', stock_qty: 3, min_stock_qty: 1, storage_location: 'ห้องเก็บของ' },
  { name: 'น้ำยาถูพื้น',           category: 'ทำความสะอาด', usage_tag: 'ทำความสะอาด', unit: 'ขวด', stock_qty: 5, min_stock_qty: 2, storage_location: 'ห้องเก็บของ' },
  { name: 'น้ำยาล้างห้องน้ำ',      category: 'ทำความสะอาด', usage_tag: 'ทำความสะอาด', unit: 'ขวด', stock_qty: 5, min_stock_qty: 2, storage_location: 'ห้องเก็บของ' },
  { name: 'น้ำยาปรับผ้านุ่ม',      category: 'ทำความสะอาด', usage_tag: 'ซักผ้า',     unit: 'ขวด', stock_qty: 3, min_stock_qty: 1, storage_location: 'ห้องเก็บของ' },
  { name: 'กระดาษชำระห้องพัก',    category: 'ทำความสะอาด', usage_tag: 'ห้องพัก',     unit: 'ม้วน', stock_qty: 30, min_stock_qty: 10, storage_location: 'ห้องเก็บของ' },
  // อะไหล่
  { name: 'หลอดไฟ',                          category: 'อะไหล่', usage_tag: 'ซ่อมบำรุง', unit: 'หลอด', stock_qty: 10, min_stock_qty: 3, storage_location: 'ห้องช่าง' },
  { name: 'แบตเตอรี่ AAA (รีโมท)',            category: 'อะไหล่', usage_tag: 'ซ่อมบำรุง', unit: 'ก้อน', stock_qty: 12, min_stock_qty: 4, storage_location: 'ห้องช่าง' },
  { name: 'แบตเตอรี่ AA (ประตู/อุปกรณ์)',      category: 'อะไหล่', usage_tag: 'ซ่อมบำรุง', unit: 'ก้อน', stock_qty: 12, min_stock_qty: 4, storage_location: 'ห้องช่าง' },
  { name: 'เมนบอร์ดแอร์',                    category: 'อะไหล่', usage_tag: 'ซ่อมบำรุง', unit: 'ชิ้น', stock_qty: 1, min_stock_qty: 1, storage_location: 'ห้องช่าง' },
  { name: 'ลูกหนูแอร์',                      category: 'อะไหล่', usage_tag: 'ซ่อมบำรุง', unit: 'ตัว',  stock_qty: 2, min_stock_qty: 1, storage_location: 'ห้องช่าง' },
  { name: 'อะไหล่แอร์อื่น ๆ',                category: 'อะไหล่', usage_tag: 'ซ่อมบำรุง', unit: 'ชิ้น', stock_qty: 2, min_stock_qty: 1, storage_location: 'ห้องช่าง' },
];

function supUuid() { return 'sup-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8); }

function loadSupItems() {
  try { const s = localStorage.getItem(SUP_ITEMS_KEY); if (s) return JSON.parse(s); } catch {}
  const ts = conNow();
  const seeded = SUP_SEED.map((p,i) => ({
    item_id: 'seed-' + i,
    item_name: p.name,
    category: p.category,
    usage_tag: p.usage_tag,
    unit: p.unit,
    stock_qty: p.stock_qty,
    min_stock_qty: p.min_stock_qty,
    storage_location: p.storage_location,
    requestable: true,
    active: true,
    created_at: ts,
    updated_at: ts,
  }));
  localStorage.setItem(SUP_ITEMS_KEY, JSON.stringify(seeded));
  return seeded;
}
function saveSupItems(d) { localStorage.setItem(SUP_ITEMS_KEY, JSON.stringify(d)); }

function loadSupRequests() {
  try { return JSON.parse(localStorage.getItem(SUP_REQUESTS_KEY) || '[]'); } catch { return []; }
}
function saveSupRequests(d) { localStorage.setItem(SUP_REQUESTS_KEY, JSON.stringify(d)); }

function loadSupStockLog() {
  try { return JSON.parse(localStorage.getItem(SUP_STOCK_LOG_KEY) || '[]'); } catch { return []; }
}
function saveSupStockLog(d) { localStorage.setItem(SUP_STOCK_LOG_KEY, JSON.stringify(d)); }

// Human-friendly "requested X ago" — no external deps.
function supTimeAgo(iso) {
  if (!iso) return '';
  const then = new Date(iso).getTime();
  if (!then) return '';
  const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
  if (secs < 60) return 'เมื่อสักครู่';
  const mins = Math.floor(secs / 60);
  if (mins < 60) return `${mins} นาทีที่แล้ว`;
  const hrs = Math.floor(mins / 60);
  if (hrs < 24) return `${hrs} ชม.ที่แล้ว`;
  const days = Math.floor(hrs / 24);
  return `${days} วันที่แล้ว`;
}

// Shared list of room numbers for the "ห้องพัก" use-location option. Falls back to
// a static list when HD.ROOMS_BY_FLOOR is not available (defensive; HD is loaded synchronously).
function supAllRooms() {
  try { return Object.values(HD.ROOMS_BY_FLOOR).flat().map(String); } catch { return []; }
}

// Count of currently pending (REQUESTED) items — used by the NAV badge.
function supPendingCount() {
  return loadSupRequests().filter(r => r.status === SUP_STATUS.REQUESTED).length;
}

function SupplyPage({ userCode, userName, onRequestsChange }) {
  const isOwner = isOwnerCode(userCode);
  const [items, setItems] = useS(loadSupItems);
  const [requests, setRequests] = useS(loadSupRequests);
  const [stockLog, setStockLog] = useS(loadSupStockLog);
  const [categoryFilter, setCategoryFilter] = useS('ทั้งหมด');
  const [feedback, setFeedback] = useS('');
  const [requestFor, setRequestFor] = useS(null);      // item currently being requested (opens form popup)
  const [editItem, setEditItem] = useS(null);           // owner: add/edit item popup
  const [restockItem, setRestockItem] = useS(null);     // owner: restock popup
  const [restockQty, setRestockQty] = useS('');
  const [adjustItem, setAdjustItem] = useS(null);       // owner: stocktake / adjust popup
  const [adjustQty, setAdjustQty] = useS('');
  const [adjustNote, setAdjustNote] = useS('');
  const [showHistory, setShowHistory] = useS(false);
  const [showManage, setShowManage] = useS(false);
  const [showStockLog, setShowStockLog] = useS(false);

  const persistItems = (itms) => { setItems(itms); saveSupItems(itms); };
  const persistRequests = (rqs) => {
    setRequests(rqs);
    saveSupRequests(rqs);
    if (onRequestsChange) onRequestsChange(rqs.filter(r => r.status === SUP_STATUS.REQUESTED).length);
  };
  const persistStockLog = (lg) => { setStockLog(lg); saveSupStockLog(lg); };
  // Append a single log entry. Called from every code path that mutates stock_qty.
  const appendStockLog = (entry) => {
    setStockLog(prev => {
      const next = [{
        log_id: supUuid(),
        actor: userName,
        created_at: new Date().toISOString(),
        note: '',
        ref_id: null,
        ...entry,
      }, ...prev];
      saveSupStockLog(next);
      return next;
    });
  };
  const flash = (msg, kind) => {
    setFeedback({ msg, kind: kind || 'ok' });
    setTimeout(() => setFeedback(''), 2600);
  };

  const activeItems = items.filter(i => i.active);
  const visibleItems = categoryFilter === 'ทั้งหมด'
    ? activeItems
    : activeItems.filter(i => i.category === categoryFilter);
  const pending = requests.filter(r => r.status === SUP_STATUS.REQUESTED);
  const history = requests
    .filter(r => r.status !== SUP_STATUS.REQUESTED)
    .slice()
    .sort((a,b) => (b.delivered_at || b.cancelled_at || '').localeCompare(a.delivered_at || a.cancelled_at || ''))
    .slice(0, 30);

  /* ── Staff/owner: submit a supply request. Stock is NOT decremented here —
     only when the owner confirms delivery. ── */
  const submitRequest = ({ item, quantity, note }) => {
    const qty = parseInt(quantity, 10);
    if (!qty || qty <= 0) return { ok: false, msg: 'ใส่จำนวนให้ถูกต้อง' };
    const nowIso = new Date().toISOString();
    const newReq = {
      request_id: supUuid(),
      item_id: item.item_id,
      item_name: item.item_name,
      category: item.category,
      quantity: qty,
      unit: item.unit,
      use_location_type: '',
      use_location_value: '',
      note: (note || '').trim(),
      status: SUP_STATUS.REQUESTED,
      requested_at: nowIso,
      requested_by: userName,
      delivered_at: null,
      delivered_by: null,
      cancelled_at: null,
      cancelled_by: null,
    };
    persistRequests([newReq, ...requests]);
    flash(`ส่งคำขอ: ${item.item_name} × ${qty}`, 'ok');
    return { ok: true };
  };

  /* ── Owner: confirm delivery. Deducts stock now. Refuses if short. ── */
  const confirmDeliver = (req) => {
    if (!isOwner) return;
    const idx = items.findIndex(i => i.item_id === req.item_id);
    if (idx === -1) { flash('ไม่พบสินค้าในสต็อก', 'err'); return; }
    const item = items[idx];
    if (item.stock_qty < req.quantity) {
      flash(`สต็อกไม่พอ: ${item.item_name} (คงเหลือ ${item.stock_qty})`, 'err');
      return;
    }
    const ts = conNow();
    const nowIso = new Date().toISOString();
    const stockBefore = item.stock_qty;
    const stockAfter = stockBefore - req.quantity;
    const nextItems = [...items];
    nextItems[idx] = { ...item, stock_qty: stockAfter, updated_at: ts };
    const nextReqs = requests.map(r => r.request_id === req.request_id
      ? { ...r, status: SUP_STATUS.DELIVERED, delivered_at: nowIso, delivered_by: userName }
      : r);
    persistItems(nextItems);
    persistRequests(nextReqs);
    appendStockLog({
      item_id: item.item_id, item_name: item.item_name,
      direction: 'out', qty: req.quantity,
      reason: SUP_LOG_REASON.DELIVERY, ref_id: req.request_id,
      stock_before: stockBefore, stock_after: stockAfter,
      note: `เบิกโดย ${req.requested_by || '-'}`,
    });
    if (stockAfter <= (item.min_stock_qty || 0)) {
      flash(`จัดส่งแล้ว · แจ้งเตือน: ${item.item_name} เหลือ ${stockAfter} ${item.unit}`, 'warn');
    } else {
      flash(`จัดส่งแล้ว: ${req.item_name} × ${req.quantity}`, 'ok');
    }
  };

  /* ── Cancel a pending request. Stock untouched.
     Owner can cancel any pending request; staff can cancel only their own. ── */
  const cancelRequest = (req) => {
    if (!isOwner && req.requested_by !== userName) return;
    const nowIso = new Date().toISOString();
    const next = requests.map(r => r.request_id === req.request_id
      ? { ...r, status: SUP_STATUS.CANCELLED, cancelled_at: nowIso, cancelled_by: userName }
      : r);
    persistRequests(next);
    flash(`ยกเลิก: ${req.item_name}`, 'warn');
  };

  /* ── Owner: restock ── */
  const submitRestock = () => {
    const qty = parseInt(restockQty, 10);
    if (!qty || qty <= 0 || !restockItem) return;
    const idx = items.findIndex(i => i.item_id === restockItem.item_id);
    if (idx === -1) return;
    const ts = conNow();
    const stockBefore = items[idx].stock_qty;
    const stockAfter = stockBefore + qty;
    const next = [...items];
    next[idx] = { ...next[idx], stock_qty: stockAfter, updated_at: ts };
    persistItems(next);
    appendStockLog({
      item_id: restockItem.item_id, item_name: restockItem.item_name,
      direction: 'in', qty,
      reason: SUP_LOG_REASON.RESTOCK,
      stock_before: stockBefore, stock_after: stockAfter,
    });
    flash(`เติมสต็อก: ${restockItem.item_name} +${qty}`, 'ok');
    setRestockItem(null); setRestockQty('');
  };

  /* ── Owner: adjust to physical count. Compares actual count to system, logs the
     difference. Used for stocktakes when the recorded qty drifts from reality. ── */
  const submitAdjust = () => {
    if (!isOwner || !adjustItem) return;
    const parsed = adjustQty === '' ? NaN : Number(adjustQty);
    if (!Number.isFinite(parsed) || parsed < 0) { flash('ใส่จำนวนจริงที่นับได้', 'err'); return; }
    const actual = Math.floor(parsed);
    const idx = items.findIndex(i => i.item_id === adjustItem.item_id);
    if (idx === -1) return;
    const stockBefore = items[idx].stock_qty;
    const diff = actual - stockBefore;
    if (diff === 0) {
      flash('จำนวนตรงกันอยู่แล้ว', 'ok');
      setAdjustItem(null); setAdjustQty(''); setAdjustNote('');
      return;
    }
    const ts = conNow();
    const next = [...items];
    next[idx] = { ...next[idx], stock_qty: actual, updated_at: ts };
    persistItems(next);
    appendStockLog({
      item_id: adjustItem.item_id, item_name: adjustItem.item_name,
      direction: diff > 0 ? 'in' : 'out',
      qty: Math.abs(diff),
      reason: SUP_LOG_REASON.ADJUST,
      stock_before: stockBefore, stock_after: actual,
      note: adjustNote.trim() || `ตรวจนับจริง ${actual} ${adjustItem.unit || ''}`,
    });
    flash(`ปรับสต็อก: ${adjustItem.item_name} ${stockBefore} → ${actual}`, diff > 0 ? 'ok' : 'warn');
    setAdjustItem(null); setAdjustQty(''); setAdjustNote('');
  };

  /* ── Owner: add/edit item ── */
  const saveItem = (draft) => {
    const ts = conNow();
    if (!draft.item_name || !draft.item_name.trim()) return;
    let next = [...items];
    if (draft.item_id) {
      const idx = next.findIndex(i => i.item_id === draft.item_id);
      if (idx !== -1) next[idx] = { ...next[idx], ...draft, updated_at: ts };
    } else {
      next.push({
        item_id: supUuid(),
        item_name: draft.item_name.trim(),
        category: draft.category || SUP_CATS[0],
        usage_tag: draft.usage_tag || draft.category || '',
        unit: draft.unit || 'ชิ้น',
        stock_qty: Number(draft.stock_qty) || 0,
        min_stock_qty: Number(draft.min_stock_qty) || 0,
        storage_location: draft.storage_location || '',
        requestable: draft.requestable !== false,
        active: true,
        created_at: ts,
        updated_at: ts,
      });
    }
    persistItems(next);
    setEditItem(null);
  };

  const stockLevelClass = (item) => {
    if (item.stock_qty <= 0) return 'stock-out';
    if (item.stock_qty <= (item.min_stock_qty || 0)) return 'stock-low';
    return 'stock-normal';
  };
  const pendingCountLabel = pending.length === 0 ? 'ยังไม่มีคำขอ' : `รอจัดส่ง ${pending.length} รายการ`;

  return (
    <div className="page dash sup-dash-page">
      <div className="dash-body sup-body">
        {/* ── LEFT: stock ── */}
        <div className="dash-left sup-left">
          <div className="sup-grid">
            {visibleItems.map(item => {
              const disabled = !item.requestable || item.stock_qty <= 0;
              return (
                <div key={item.item_id} className="goods-card-wrap">
                  <div className={'sup-card' + (disabled ? ' sup-card-disabled' : '')}
                    onClick={() => { if (!disabled) setRequestFor(item); }}>
                    {isOwner && (
                      <div className="goods-owner-tools" onClick={e => e.stopPropagation()}>
                        <button className="goods-tool-btn" title="เติมสต็อก" onClick={() => { setRestockItem(item); setRestockQty(''); }}>＋</button>
                        <button className="goods-tool-btn" title="แก้ไข" onClick={() => setEditItem({...item})}>✎</button>
                      </div>
                    )}
                    <div className="goods-card-top">
                      <div className="sup-card-name">{item.item_name}</div>
                    </div>
                    <div className="goods-card-bottom">
                      <div className={'sup-card-stock ' + stockLevelClass(item)}>
                        {item.stock_qty <= 0 ? 'หมด' : `${item.stock_qty} ${item.unit || ''}`}
                      </div>
                    </div>
                  </div>
                </div>
              );
            })}
            {isOwner && (
              <div className="goods-add-card" onClick={() => setEditItem({
                item_name: '', category: SUP_CATS[0], usage_tag: SUP_CATS[0],
                unit: 'ชิ้น', stock_qty: 0, min_stock_qty: 1,
                storage_location: '', requestable: true, active: true,
              })}>
                <span className="con-add-icon">+</span>
                <span className="con-add-label">เพิ่มรายการ</span>
              </div>
            )}
            {visibleItems.length === 0 && <div className="sup-empty">ไม่พบรายการในหมวดนี้</div>}
          </div>
        </div>

        {/* ── RIGHT: filter (สั่งของ) + pending queue (จ่ายของ) ── */}
        <div className="dash-right sup-right-panel">
          <div className="sup-section sup-section-order">
            <div className="sup-section-head">
              <h4 className="sup-section-title">หมวดหมู่</h4>
              <div className="sup-section-actions">
                <button className="sup-manage-btn" onClick={() => setShowHistory(true)}>ประวัติ</button>
                {isOwner && (
                  <button className="sup-manage-btn" onClick={() => setShowStockLog(true)}>สต็อกล็อก</button>
                )}
                {isOwner && (
                  <button className="sup-manage-btn" onClick={() => setShowManage(true)}>จัดการ</button>
                )}
              </div>
            </div>
            <div className="sup-cat-chips">
              {['ทั้งหมด', ...SUP_CATS].map(c => (
                <button key={c} type="button"
                  className={'sup-cat-chip' + (categoryFilter === c ? ' active' : '')}
                  onClick={() => setCategoryFilter(c)}>{c}</button>
              ))}
            </div>
          </div>

          <div className="sup-section sup-section-dispatch">
            <div className="sup-pending-head">
              <h4 className="sup-section-title">จ่ายของ</h4>
              <span className={'sup-pending-count' + (pending.length > 0 ? ' has' : '')}>{pendingCountLabel}</span>
            </div>
          {feedback && (
            <div className={'sup-feedback sup-feedback-' + (feedback.kind || 'ok')}>{feedback.msg}</div>
          )}
          <div className="sup-pending-list">
            {pending.length === 0 && (
              <div className="sup-empty">
                {isOwner ? 'ยังไม่มีคำขอจากพนักงาน' : 'ยังไม่มีคำขอ · เลือกรายการจากด้านซ้ายเพื่อเบิก'}
              </div>
            )}
            {pending.map(req => {
              const stockNow = items.find(i => i.item_id === req.item_id);
              const stockAvail = stockNow ? stockNow.stock_qty : 0;
              const shortage = stockAvail < req.quantity;
              return (
                <div key={req.request_id} className={'sup-pending-card fresh' + (shortage ? ' shortage' : '')}>
                  <div className="spc-top">
                    <div className="spc-name">{req.item_name}</div>
                    <div className="spc-cat">{req.category}</div>
                  </div>
                  <div className="spc-row">
                    <div className="spc-qty">{req.quantity} {req.unit || ''}</div>
                  </div>
                  {req.note && <div className="spc-note">“{req.note}”</div>}
                  <div className="spc-meta">
                    <span>โดย {req.requested_by || '-'}</span>
                    <span>·</span>
                    <span>{supTimeAgo(req.requested_at)}</span>
                    <span>·</span>
                    <span className={shortage ? 'stock-out' : 'stock-normal'}>
                      สต็อก {stockAvail} {req.unit || ''}
                    </span>
                  </div>
                  {isOwner ? (
                    <div className="spc-actions">
                      <button className="spc-btn spc-deliver" disabled={shortage}
                        onClick={() => confirmDeliver(req)}>จัดส่งแล้ว</button>
                      <button className="spc-btn spc-cancel" onClick={() => cancelRequest(req)}>ยกเลิก</button>
                    </div>
                  ) : (
                    <div className="spc-actions spc-actions-readonly">
                      <span className="spc-waiting">รอเจ้าของยืนยัน</span>
                      {req.requested_by === userName && (
                        <button className="spc-btn spc-cancel" onClick={() => cancelRequest(req)}>ยกเลิก</button>
                      )}
                    </div>
                  )}
                </div>
              );
            })}
          </div>
          </div>
        </div>
      </div>

      {/* ── Request modal (staff + owner) ── */}
      {requestFor && (
        <RequestSupplyForm
          item={requestFor}
          onClose={() => setRequestFor(null)}
          onSubmit={(payload) => {
            const res = submitRequest({ item: requestFor, ...payload });
            if (res.ok) setRequestFor(null);
            else flash(res.msg, 'err');
          }}
        />
      )}

      {/* ── History popup ── */}
      {showHistory && (
        <div className="scrim" onMouseDown={e => { if (e.target === e.currentTarget) setShowHistory(false); }}>
          <div className="popup sup-history-popup" onMouseDown={e => e.stopPropagation()}>
            <div className="popup-head">
              <h3>ประวัติการจัดส่ง / ยกเลิก</h3>
              <button className="popup-x" onClick={() => setShowHistory(false)}>×</button>
            </div>
            <div className="sup-history-list">
              {history.length === 0 && <div className="sup-empty">ยังไม่มีประวัติ</div>}
              {history.map(r => (
                <div key={r.request_id} className={'sup-history-row ' + (r.status === SUP_STATUS.DELIVERED ? 'ok' : 'cancel')}>
                  <span className="shr-status">{r.status === SUP_STATUS.DELIVERED ? 'จัดส่งแล้ว' : 'ยกเลิก'}</span>
                  <span className="shr-name">{r.item_name}</span>
                  <span className="shr-qty">{r.quantity} {r.unit}</span>
                  <span className="shr-actor">{r.status === SUP_STATUS.DELIVERED ? r.delivered_by : r.cancelled_by}</span>
                  <span className="shr-time">{supTimeAgo(r.delivered_at || r.cancelled_at)}</span>
                </div>
              ))}
            </div>
          </div>
        </div>
      )}

      {/* ── Manage items popup (owner) ── */}
      {showManage && isOwner && (
        <div className="scrim" onMouseDown={e => { if (e.target === e.currentTarget) setShowManage(false); }}>
          <div className="popup sup-manage-popup" onMouseDown={e => e.stopPropagation()}>
            <div className="popup-head">
              <h3>จัดการรายการ ({items.length})</h3>
              <button className="popup-x" onClick={() => setShowManage(false)}>×</button>
            </div>
            <button className="sup-add-btn" onClick={() => setEditItem({
              item_name: '', category: SUP_CATS[0], usage_tag: SUP_CATS[0],
              unit: 'ชิ้น', stock_qty: 0, min_stock_qty: 1,
              storage_location: '', requestable: true, active: true,
            })}>+ เพิ่มรายการ</button>
            <div className="sup-manage-list">
              {items.map(it => (
                <div key={it.item_id} className={'sup-manage-row' + (!it.active ? ' inactive' : '')}>
                  <span className="sup-m-name">{it.item_name}</span>
                  <span className="sup-m-cat">{it.category}</span>
                  <span className={'sup-m-stock ' + stockLevelClass(it)}>{it.stock_qty} {it.unit}</span>
                  <button onClick={() => { setRestockItem(it); setRestockQty(''); }}>เติม</button>
                  <button onClick={() => { setAdjustItem(it); setAdjustQty(String(it.stock_qty)); setAdjustNote(''); }}>ปรับ</button>
                  <button onClick={() => setEditItem({...it})}>แก้ไข</button>
                </div>
              ))}
            </div>
          </div>
        </div>
      )}

      {/* ── Edit item popup (owner) ── */}
      {editItem && isOwner && (
        <div className="scrim" onMouseDown={e => { if (e.target === e.currentTarget) setEditItem(null); }}>
          <div className="popup sup-edit-popup" onMouseDown={e => e.stopPropagation()}>
            <div className="popup-head">
              <h3>{editItem.item_id ? 'แก้ไขรายการ' : 'เพิ่มรายการ'}</h3>
              <button className="popup-x" onClick={() => setEditItem(null)}>×</button>
            </div>
            <label>ชื่อ<input value={editItem.item_name} onChange={e => setEditItem({...editItem, item_name: e.target.value})} /></label>
            <label>หมวด
              <select value={editItem.category} onChange={e => setEditItem({...editItem, category: e.target.value})}>
                {SUP_CATS.map(c => <option key={c} value={c}>{c}</option>)}
              </select>
            </label>
            <label>Tag การใช้งาน<input value={editItem.usage_tag || ''} onChange={e => setEditItem({...editItem, usage_tag: e.target.value})} placeholder="เช่น ครัว, ห้องพัก, ซ่อมบำรุง" /></label>
            <label>หน่วย<input value={editItem.unit || ''} onChange={e => setEditItem({...editItem, unit: e.target.value})} /></label>
            <label>สต็อก<input type="number" value={editItem.stock_qty} onChange={e => setEditItem({...editItem, stock_qty: Number(e.target.value)})} /></label>
            <label>แจ้งเตือนเมื่อเหลือ<input type="number" value={editItem.min_stock_qty} onChange={e => setEditItem({...editItem, min_stock_qty: Number(e.target.value)})} /></label>
            <label>ที่เก็บ<input value={editItem.storage_location || ''} onChange={e => setEditItem({...editItem, storage_location: e.target.value})} /></label>
            <label>ให้เบิกได้
              <select value={String(editItem.requestable !== false)} onChange={e => setEditItem({...editItem, requestable: e.target.value === 'true'})}>
                <option value="true">ใช่</option><option value="false">ไม่</option>
              </select>
            </label>
            {editItem.item_id && (
              <label>เปิดใช้
                <select value={String(editItem.active !== false)} onChange={e => setEditItem({...editItem, active: e.target.value === 'true'})}>
                  <option value="true">ใช่</option><option value="false">ไม่</option>
                </select>
              </label>
            )}
            <button className="sup-save-btn" onClick={() => saveItem(editItem)}>บันทึก</button>
          </div>
        </div>
      )}

      {/* ── Restock popup (owner) ── */}
      {restockItem && isOwner && (
        <div className="scrim" onMouseDown={e => { if (e.target === e.currentTarget) setRestockItem(null); }}>
          <div className="popup goods-restock-popup" onMouseDown={e => e.stopPropagation()}>
            <div className="popup-head"><h3>เติมสต็อก</h3><button className="popup-x" onClick={() => setRestockItem(null)}>×</button></div>
            <div className="goods-restock-body">
              <div className="goods-restock-name">{restockItem.item_name}</div>
              <div className="goods-restock-current">คงเหลือ {restockItem.stock_qty} {restockItem.unit || ''}</div>
              <label>จำนวนที่เติม
                <input type="number" min="1" autoFocus value={restockQty} onChange={e => setRestockQty(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') submitRestock(); }} placeholder="0" />
              </label>
              <button className="goods-restock-btn" onClick={submitRestock}>ยืนยัน</button>
            </div>
          </div>
        </div>
      )}

      {/* ── Adjust (stocktake) popup (owner). Sets stock to actual counted qty
           and logs the difference under reason=adjust. ── */}
      {adjustItem && isOwner && (
        <div className="scrim" onMouseDown={e => { if (e.target === e.currentTarget) setAdjustItem(null); }}>
          <div className="popup goods-restock-popup" onMouseDown={e => e.stopPropagation()}>
            <div className="popup-head"><h3>ปรับสต็อกจริง</h3><button className="popup-x" onClick={() => setAdjustItem(null)}>×</button></div>
            <div className="goods-restock-body">
              <div className="goods-restock-name">{adjustItem.item_name}</div>
              <div className="goods-restock-current">ในระบบ {adjustItem.stock_qty} {adjustItem.unit || ''}</div>
              <label>จำนวนจริงที่นับได้
                <input type="number" min="0" autoFocus value={adjustQty} onChange={e => setAdjustQty(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') submitAdjust(); }} placeholder="0" />
              </label>
              <label>หมายเหตุ (ไม่บังคับ)
                <input value={adjustNote} onChange={e => setAdjustNote(e.target.value)} placeholder="เช่น ตรวจนับประจำเดือน / ของหาย" />
              </label>
              <button className="goods-restock-btn" onClick={submitAdjust}>ยืนยัน</button>
            </div>
          </div>
        </div>
      )}

      {/* ── Stock log viewer (owner). Shows every in/out on stock_qty. ── */}
      {showStockLog && isOwner && (
        <div className="scrim" onMouseDown={e => { if (e.target === e.currentTarget) setShowStockLog(false); }}>
          <div className="popup sup-history-popup" onMouseDown={e => e.stopPropagation()}>
            <div className="popup-head">
              <h3>ประวัติสต็อก ({stockLog.length})</h3>
              <button className="popup-x" onClick={() => setShowStockLog(false)}>×</button>
            </div>
            <div className="sup-history-list">
              {stockLog.length === 0 && <div className="sup-empty">ยังไม่มีประวัติสต็อก</div>}
              {stockLog.slice(0, 100).map(e => (
                <div key={e.log_id} className={'sup-history-row ' + (e.direction === 'in' ? 'ok' : 'cancel')}>
                  <span className="shr-status">{SUP_LOG_REASON_LABEL[e.reason] || e.reason}</span>
                  <span className="shr-name">{e.item_name}</span>
                  <span className="shr-qty">{e.direction === 'in' ? '+' : '−'}{e.qty} · {e.stock_before}→{e.stock_after}</span>
                  <span className="shr-actor">{e.actor}</span>
                  <span className="shr-time">{supTimeAgo(e.created_at)}</span>
                </div>
              ))}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

/* Request-supply popup (staff & owner). Keeps its own local form state so
   parent re-renders don't wipe in-progress typing. */
function RequestSupplyForm({ item, onClose, onSubmit }) {
  const [qty, setQty] = useS('1');
  const [note, setNote] = useS('');

  const submit = () => onSubmit({
    quantity: qty,
    use_location_type: '',
    use_location_value: '',
    note,
  });

  const stockCls = item.stock_qty <= 0 ? 'stock-out'
    : item.stock_qty <= (item.min_stock_qty || 0) ? 'stock-low'
    : 'stock-normal';
  return (
    <div className="scrim" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="popup sup-request-popup" onMouseDown={e => e.stopPropagation()}>
        <div className="popup-head sup-req-head">
          <div className="sup-req-head-info">
            <div className="sup-req-head-name">{item.item_name}</div>
            <div className="sup-req-head-meta">
              <span className="sup-req-head-cat">{item.category}</span>
              <span className="sup-req-head-dot">·</span>
              <span className={'sup-req-head-stock ' + stockCls}>คงเหลือ {item.stock_qty} {item.unit || ''}</span>
            </div>
          </div>
          <button className="popup-x" onClick={onClose}>×</button>
        </div>
        <div className="sup-req-body">
          <label>จำนวน
            <input type="number" min="1" autoFocus value={qty} onChange={e => setQty(e.target.value)} />
          </label>
          <label>หมายเหตุ (ถ้ามี)
            <input value={note} onChange={e => setNote(e.target.value)} placeholder="เช่น รีบใช้ก่อน 15:00" />
          </label>
          <button className="sup-save-btn" onClick={submit}>ส่งคำขอ</button>
        </div>
      </div>
    </div>
  );
}

/* ---------------- Maintenance page ---------------- */
const MAINT_ROOMS = [
  '102','104','106',
  '201','202','203','204','205','206',
  '301','302','303','304','305','306','307','308','309',
  '401','402','403','404','405','406','407','408','409',
  '501','502','503','504','505','506','507','508','509',
  '601','602','603','604','605',
];
const MAINT_ROOMS_BY_FLOOR = MAINT_ROOMS.reduce((acc, rm) => {
  const floor = 'ชั้น ' + rm[0];
  (acc[floor] = acc[floor] || []).push(rm);
  return acc;
}, {});
const AC_ROOMS_DOUBLE = ['602','603','604','605'];
const MAINT_TYPES = ['แอร์','ชักโครก','เตียง','ทีวี','กระจก','ประตู/หน้าต่าง','ไฟฟ้า','ประปา','อื่นๆ'];
const MAINT_KEY = 'hataara_maintenance_v1';

/* Multi-room picker popup for creating a maintenance record against one or
   more rooms at once. Reuses .popup/.rp-body/.rp-floor/.rp-grid/.rm tokens so
   it stays visually consistent with the Payment-Group RoomPicker in popups.jsx. */
function MaintRoomPicker({ onClose, onConfirm, initialSelected }) {
  const [selected, setSelected] = useS(initialSelected || []);
  const toggle = (rm) => setSelected(prev => prev.includes(rm) ? prev.filter(r => r !== rm) : [...prev, rm]);
  const clear = () => setSelected([]);
  return (
    <div className="scrim" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="popup maint-room-picker" style={{ width: 520 }} onMouseDown={e => e.stopPropagation()}>
        <div className="popup-head">
          <h3>เลือกห้อง (แจ้งซ่อม)</h3>
          <button className="popup-x" aria-label="ปิด" title="ปิด" onClick={onClose}>×</button>
        </div>
        <div className="rp-ms-hint">คลิกเลือกได้หลายห้อง · กด "ยืนยัน" เมื่อครบ</div>
        <div className="rp-body">
          {Object.entries(MAINT_ROOMS_BY_FLOOR).map(([floor, rooms]) => (
            <div className="rp-floor" key={floor}>
              <h4>{floor}</h4>
              <div className="rp-grid">
                {rooms.map(rm => {
                  const sel = selected.includes(rm);
                  return (
                    <button key={rm} type="button"
                      className={'rm' + (sel ? ' rp-sel' : '')}
                      onClick={() => toggle(rm)}>{rm}</button>
                  );
                })}
              </div>
            </div>
          ))}
        </div>
        <div className="rp-confirm-bar">
          <span className="rp-sel-count">
            {selected.length === 0
              ? 'ยังไม่ได้เลือกห้อง'
              : `เลือกแล้ว ${selected.length} ห้อง · ${selected.join(', ')}`}
          </span>
          <button type="button" className="pbtn ghost" onClick={clear} disabled={selected.length === 0}>ล้าง</button>
          <button type="button" className="pbtn primary" disabled={selected.length === 0}
            onClick={() => selected.length > 0 && onConfirm(selected)}>
            ยืนยัน ({selected.length})
          </button>
        </div>
      </div>
    </div>
  );
}

function loadMaintData() {
  try { return JSON.parse(localStorage.getItem(MAINT_KEY) || '{}'); } catch { return {}; }
}
function saveMaintData(d) {
  localStorage.setItem(MAINT_KEY, JSON.stringify(d));
}

const emptyRpDraft = () => ({ rooms: [], type: '', acUnit: 'ห้องนอน', reportDate: new Date().toISOString().slice(0,10), detail: '', repairDate: '', cost: '', tech: '', note: '' });

function MaintenancePage() {
  const [data, setData] = useS(loadMaintData);
  const [selectedRoom, setSelectedRoom] = useS(null);
  const [filterType, setFilterType] = useS('ทั้งหมด');
  // Main-view (right-panel) create-work form — always visible (no open/close).
  const [rpDraft, setRpDraft] = useS(emptyRpDraft);
  const [rpPicker, setRpPicker] = useS(false);
  // Per-record chosen completion date in the room-detail view (id → YYYY-MM-DD).
  const [completeDates, setCompleteDates] = useS({});

  const save = (d) => { setData(d); saveMaintData(d); };

  // Create a repair record from the MAIN right panel and attach it to each
  // selected room. Record shape stays backward-compatible with the detail
  // view: `date` mirrors the repair date (or report date) and `resolved`
  // is derived from whether a repair date was entered. When multiple rooms
  // are picked, one record per room is written (each gets a unique id).
  const addRpRecord = () => {
    const rooms = rpDraft.rooms || [];
    if (rooms.length === 0 || !rpDraft.detail.trim()) return;
    const now = Date.now();
    const next = { ...data };
    rooms.forEach((room, i) => {
      const isDouble = AC_ROOMS_DOUBLE.includes(room);
      const rec = {
        id: now + i,
        type: rpDraft.type,
        acUnit: rpDraft.type === 'แอร์' && isDouble ? rpDraft.acUnit : 'ห้องนอน',
        reportDate: rpDraft.reportDate,
        detail: rpDraft.detail,
        repairDate: rpDraft.repairDate,
        cost: rpDraft.cost,
        tech: rpDraft.tech,
        note: rpDraft.note,
        date: rpDraft.repairDate || rpDraft.reportDate,
        resolved: !!rpDraft.repairDate,
      };
      next[room] = [rec, ...(next[room] || [])];
    });
    save(next);
    setRpDraft(emptyRpDraft());
  };

  const deleteRecord = (room, id) => {
    save({ ...data, [room]: (data[room] || []).filter(r => r.id !== id) });
  };

  // Mark an open repair job as completed. Sets the repair (completed) date —
  // either the date the user picked/confirmed, or today by default — and flips
  // the record to resolved so it moves to the "เสร็จแล้ว" list. Purely a
  // status/date update on the local maintenance record; no schema change.
  const completeRecord = (room, id, repairDate) => {
    const d = repairDate || new Date().toISOString().slice(0, 10);
    save({ ...data, [room]: (data[room] || []).map(r => r.id === id
      ? { ...r, resolved: true, status: 'resolved', repairDate: d, date: d }
      : r) });
  };

  // last AC wash per room
  const lastAcWash = (room, unit) => {
    const recs = (data[room] || []).filter(r => r.type === 'แอร์' && (!unit || r.acUnit === unit));
    if (!recs.length) return null;
    return recs[0].date;
  };

  const daysSince = (dateStr) => {
    if (!dateStr) return null;
    return Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000);
  };

  const roomRecords = selectedRoom ? (data[selectedRoom] || []) : [];
  const filteredRecords = filterType === 'ทั้งหมด' ? roomRecords : roomRecords.filter(r => r.type === filterType);
  const isDouble = selectedRoom && AC_ROOMS_DOUBLE.includes(selectedRoom);

  if (selectedRoom) {
    return (
      <div className="page list-page maint-page maint-detail-page">
       <div className="maint-detail-shell">
        <div className="page-header maint-detail-header">
          <div style={{display:'flex',alignItems:'center',gap:'10px'}}>
            <button className="maint-back-btn" onClick={() => setSelectedRoom(null)}>← กลับ</button>
            <span className="page-title">ห้อง {selectedRoom}</span>
            <span className="maint-detail-sub">{roomRecords.length} บันทึก</span>
          </div>
          {/* View-only detail: new repair jobs are created only from the main
              Maintenance right panel, never here. */}
          <span className="maint-detail-note">ดูอย่างเดียว · เพิ่มงานได้ที่หน้าหลัก</span>
        </div>

        {/* AC status */}
        <div className="maint-ac-row">
          {isDouble ? (
            <>
              {['ห้องนอน','ห้องนั่งเล่น'].map(unit => {
                const last = lastAcWash(selectedRoom, unit);
                const days = daysSince(last);
                return (
                  <div key={unit} className="maint-ac-card">
                    <div className="maint-ac-label">แอร์{unit}</div>
                    <div className="maint-ac-date">{last ? `ล้างล่าสุด ${last}` : 'ยังไม่มีข้อมูล'}</div>
                    {days !== null && <div className="maint-ac-days">{days} วันที่แล้ว</div>}
                  </div>
                );
              })}
            </>
          ) : (
            (() => {
              const last = lastAcWash(selectedRoom);
              const days = daysSince(last);
              return (
                <div className="maint-ac-card">
                  <div className="maint-ac-label">แอร์ห้อง {selectedRoom}</div>
                  <div className="maint-ac-date">{last ? `ล้างล่าสุด ${last}` : 'ยังไม่มีข้อมูล'}</div>
                  {days !== null && <div className="maint-ac-days">{days} วันที่แล้ว</div>}
                </div>
              );
            })()
          )}
        </div>

        {/* filter */}
        <div className="maint-filter-row">
          {['ทั้งหมด', ...MAINT_TYPES].map(t => (
            <button key={t} className={`maint-filter-btn${filterType === t ? ' active' : ''}`} onClick={() => setFilterType(t)}>{t}</button>
          ))}
        </div>

        {/* records */}
        {filteredRecords.length === 0 ? (
          <div className="maint-empty">
            <div className="maint-empty-icon">🛠️</div>
            <div className="maint-empty-title">ยังไม่มีบันทึกงาน{filterType !== 'ทั้งหมด' ? ` หมวด ${filterType}` : ''}</div>
            <div className="maint-empty-hint">เพิ่มงานซ่อมได้ที่หน้า Maintenance หลัก (แผงด้านขวา)</div>
          </div>
        ) : (
          <div className="maint-record-list">
            {filteredRecords.map(r => (
              <div key={r.id} className={'maint-record-card' + (r.resolved ? ' maint-record-done' : ' maint-record-open')}>
                <div className="maint-record-head">
                  <span className="maint-record-type">{r.type}{r.type === 'แอร์' && isDouble ? ` (${r.acUnit})` : ''}</span>
                  {r.resolved && <span className="maint-record-status done">ซ่อมแล้ว</span>}
                  {!r.resolved && (r.reportDate || r.repairDate !== undefined) && <span className="maint-record-status open">เปิดอยู่</span>}
                  <span className="maint-record-date">{r.date}</span>
                </div>
                <div className="maint-record-detail">{r.detail}</div>
                <div className="maint-record-meta">
                  {r.reportDate && <span>แจ้ง: {r.reportDate}</span>}
                  {r.repairDate && <span>ซ่อม: {r.repairDate}</span>}
                  {r.tech && <span>ช่าง: {r.tech}</span>}
                  {r.cost && <span>ค่าใช้จ่าย: {Number(r.cost).toLocaleString()} บาท</span>}
                </div>
                {r.note && <div className="maint-record-note">หมายเหตุ: {r.note}</div>}
                <div className="maint-record-actions">
                  {!r.resolved && (
                    <div className="maint-record-complete">
                      <label className="maint-complete-lbl">วันที่ซ่อมเสร็จ
                        <input type="date" className="maint-complete-date"
                          value={completeDates[r.id] || new Date().toISOString().slice(0,10)}
                          onChange={e => setCompleteDates(s => ({ ...s, [r.id]: e.target.value }))} />
                      </label>
                      <button className="maint-complete-btn" onClick={() => completeRecord(selectedRoom, r.id, completeDates[r.id])}>✓ ซ่อมเสร็จ</button>
                    </div>
                  )}
                  <button className="maint-delete-btn" onClick={() => deleteRecord(selectedRoom, r.id)}>ลบ</button>
                </div>
              </div>
            ))}
          </div>
        )}
       </div>
      </div>
    );
  }

  // room grid view — Dashboard-like 2-panel layout
  const allJobs = MAINT_ROOMS.flatMap(room => (data[room] || []).map(r => ({ ...r, room })));
  const isOpenJob = (r) => !r.resolved && r.status !== 'resolved';
  const openJobs = allJobs.filter(isOpenJob).sort((a, b) => b.id - a.id);
  const doneJobs = allJobs.filter(r => !isOpenJob(r)).sort((a, b) => b.id - a.id);
  const openJobsCount = openJobs.length;
  const doneJobsCount = doneJobs.length;
  const roomsWithIssue = MAINT_ROOMS.filter(room => (data[room] || []).some(isOpenJob)).length;
  const goRoom = (room) => { setSelectedRoom(room); setFilterType('ทั้งหมด'); };

  return (
    <div className="page dash maint-dash-page">
      <div className="dash-body">
        {/* LEFT — full room card panel matching Dashboard geometry */}
        <div className="dash-left">
          <div className="tx-grid maint-room-tx-grid">
            {MAINT_ROOMS.map(room => {
              const recs = data[room] || [];
              const count = recs.length;
              // Room-grid status is generic (multi-type): a room simply has open
              // repair job(s), only completed jobs, or none. No hardcoded AC-wash
              // overdue/near-due reminder is surfaced here.
              const openCount = recs.filter(r => !r.resolved && r.status !== 'resolved').length;
              return (
                <div key={room} className="tx-card-wrap">
                  <button
                    className={`tx-card maint-tx-card${openCount > 0 ? ' maint-has-issue-card' : ''}`}
                    onClick={() => { setSelectedRoom(room); setFilterType('ทั้งหมด'); }}>
                    <span className="tx-room">{room}</span>
                    {openCount > 0
                      ? <span className="maint-tx-dot open" title={openCount + ' งานซ่อมที่เปิดอยู่'}></span>
                      : count > 0
                        ? <span className="maint-tx-dot done" title="ซ่อมเสร็จแล้ว"></span>
                        : null}
                  </button>
                </div>
              );
            })}
          </div>
        </div>

        {/* RIGHT — maintenance side panel: create form + open/done lists */}
        <div className="dash-right maint-right-panel">
          {/* Create-work form (main view) — always visible, permanent input area.
              First card sits at the top of the panel (header removed) to align
              with Housekeeping/Dashboard right panels. */}
          {(() => {
            const rpRooms = rpDraft.rooms || [];
            const rpDouble = rpRooms.some(r => AC_ROOMS_DOUBLE.includes(r));
            const canSave = rpRooms.length > 0 && rpDraft.type !== '' && rpDraft.detail.trim().length > 0;
            const roomLabel = rpRooms.length === 0
              ? 'เลือกห้อง…'
              : rpRooms.length === 1
                ? `ห้อง ${rpRooms[0]}`
                : `${rpRooms.length} ห้อง · ${rpRooms.join(', ')}`;
            return (
              <div className="maint-rp-form" style={{gridColumn:'1/-1'}}>
                <div className="maint-rp-form-grid">
                  <div className="maint-rp-fld maint-rp-fld-bare">
                    <button type="button"
                      className={'maint-rp-room-btn' + (rpRooms.length === 0 ? ' is-empty' : '')}
                      onClick={() => setRpPicker(true)}>
                      <span className="maint-rp-room-btn-label">{roomLabel}</span>
                      <span className="maint-rp-room-btn-chev">⌄</span>
                    </button>
                  </div>
                  <div className="maint-rp-fld maint-rp-fld-bare">
                    <select
                      className={rpDraft.type === '' ? 'is-empty' : ''}
                      value={rpDraft.type}
                      onChange={e => setRpDraft({...rpDraft, type: e.target.value})}>
                      <option value="" disabled hidden>รายการเสีย/ซ่อม</option>
                      {MAINT_TYPES.map(t => <option key={t}>{t}</option>)}
                    </select>
                  </div>
                  {rpDraft.type === 'แอร์' && rpDouble && (
                    <label className="maint-rp-fld maint-rp-fld-full">ตัวแอร์
                      <select value={rpDraft.acUnit} onChange={e => setRpDraft({...rpDraft, acUnit: e.target.value})}>
                        <option>ห้องนอน</option>
                        <option>ห้องนั่งเล่น</option>
                      </select>
                    </label>
                  )}
                  <label className="maint-rp-fld">วันเสีย/วันที่แจ้ง
                    <input type="date" value={rpDraft.reportDate} onChange={e => setRpDraft({...rpDraft, reportDate: e.target.value})} />
                  </label>
                  <label className="maint-rp-fld maint-rp-fld-full">อาการ
                    <input value={rpDraft.detail} onChange={e => setRpDraft({...rpDraft, detail: e.target.value})} placeholder="เช่น แอร์ไม่เย็น, ชักโครกตัน" />
                  </label>
                  <label className="maint-rp-fld">วันที่ซ่อม
                    <input type="date" value={rpDraft.repairDate} onChange={e => setRpDraft({...rpDraft, repairDate: e.target.value})} />
                  </label>
                  <label className="maint-rp-fld">ค่าใช้จ่าย (บาท)
                    <input type="number" value={rpDraft.cost} onChange={e => setRpDraft({...rpDraft, cost: e.target.value})} placeholder="0" />
                  </label>
                  <label className="maint-rp-fld">ชื่อช่าง
                    <input value={rpDraft.tech} onChange={e => setRpDraft({...rpDraft, tech: e.target.value})} placeholder="ชื่อช่าง" />
                  </label>
                  <label className="maint-rp-fld maint-rp-fld-full">หมายเหตุ
                    <input value={rpDraft.note} onChange={e => setRpDraft({...rpDraft, note: e.target.value})} placeholder="เพิ่มเติม (ถ้ามี)" />
                  </label>
                </div>
                <div className="maint-rp-form-actions">
                  <button className="maint-rp-save" onClick={addRpRecord} disabled={!canSave}>
                    บันทึกงานซ่อม{rpRooms.length > 1 ? ` (${rpRooms.length} ห้อง)` : ''}
                  </button>
                  <button className="maint-rp-cancel" onClick={() => setRpDraft(emptyRpDraft())}>ล้าง</button>
                </div>
                {!canSave && (
                  <div className="maint-rp-form-hint">
                    {rpRooms.length === 0
                      ? 'กรุณาเลือกห้อง และกรอกอาการ เพื่อบันทึกงานซ่อม'
                      : 'กรุณากรอกอาการ (รายละเอียดการเสีย/ซ่อม) เพื่อบันทึก'}
                  </div>
                )}
              </div>
            );
          })()}

          <div className="maint-rp-stats" style={{gridColumn:'1/-1'}}>
            <div className="maint-stat-card maint-stat-open"><span className="maint-stat-num">{openJobsCount}</span><span className="maint-stat-lbl">งานเปิดอยู่</span></div>
            <div className="maint-stat-card maint-stat-done"><span className="maint-stat-num">{doneJobsCount}</span><span className="maint-stat-lbl">ซ่อมเสร็จแล้ว</span></div>
            <div className="maint-stat-card maint-stat-rooms"><span className="maint-stat-num">{roomsWithIssue}</span><span className="maint-stat-lbl">ห้องที่มีปัญหา</span></div>
          </div>

          {/* Open repair jobs */}
          <div className="maint-rp-list-block" style={{gridColumn:'1/-1'}}>
            <div className="maint-rp-list-head"><span>งานซ่อมที่เปิดอยู่</span><span className="maint-rp-list-count">{openJobsCount}</span></div>
            <div className="maint-rp-list">
              {openJobs.length === 0
                ? <div className="maint-rp-list-empty">ยังไม่มีงานเปิดอยู่</div>
                : openJobs.map(r => (
                  <div key={r.id} className="maint-job-card maint-job-open">
                    <button className="maint-job-main" onClick={() => goRoom(r.room)}>
                      <span className="maint-job-room">{r.room}</span>
                      <span className="maint-job-body">
                        <span className="maint-job-type">{r.type}</span>
                        <span className="maint-job-detail">{r.detail}</span>
                      </span>
                      <span className="maint-job-date">{r.reportDate || r.date || ''}</span>
                    </button>
                    <button className="maint-job-complete" title="ทำเครื่องหมายว่าซ่อมเสร็จ (วันนี้)" onClick={() => completeRecord(r.room, r.id)}>✓ ซ่อมเสร็จ</button>
                  </div>
                ))}
            </div>
          </div>

          {/* Completed repair jobs */}
          <div className="maint-rp-list-block" style={{gridColumn:'1/-1'}}>
            <div className="maint-rp-list-head"><span>งานซ่อมที่เสร็จแล้ว</span><span className="maint-rp-list-count">{doneJobsCount}</span></div>
            <div className="maint-rp-list">
              {doneJobs.length === 0
                ? <div className="maint-rp-list-empty">ยังไม่มีงานที่เสร็จ</div>
                : doneJobs.map(r => (
                  <button key={r.id} className="maint-job-card maint-job-done" onClick={() => goRoom(r.room)}>
                    <span className="maint-job-room">{r.room}</span>
                    <span className="maint-job-body">
                      <span className="maint-job-type">{r.type}</span>
                      <span className="maint-job-detail">{r.detail}</span>
                    </span>
                    <span className="maint-job-date">{r.repairDate || r.date || ''}</span>
                  </button>
                ))}
            </div>
          </div>

        </div>
      </div>
      {rpPicker && (
        <MaintRoomPicker
          initialSelected={rpDraft.rooms}
          onClose={() => setRpPicker(false)}
          onConfirm={(rooms) => { setRpDraft({ ...rpDraft, rooms }); setRpPicker(false); }} />
      )}
    </div>
  );
}

/* ---------------- Housekeeping page ---------------- */
function HousekeepingPage({ roomStates, dateStr, bookingRows = [], allRooms = [], cardSkin = 'hk', title = 'HOUSEKEEPING', user = null }) {
  const todayStr = fmtThaiDate(new Date());
  const [hkDateStr, setHkDateStr] = useS(todayStr);
  // A maid session lands directly on the MAID view and can't flip to FRONT.
  // Everyone else defaults to FRONT and can toggle freely.
  const isMaidUser = !!(user && user.client === 'maid');
  const [hkView, setHkView] = useS(isMaidUser ? 'maid' : 'front');
  const [hkClientState, setHkClientState] = useS({});
  const [hkExtras, setHkExtras] = useS([]);
  const [hkExtraOpen, setHkExtraOpen] = useS(false);
  const [hkExtraDraft, setHkExtraDraft] = useS({ room: '', detail: '' });
  const [hkModalRoom, setHkModalRoom] = useS(null);

  const sourceDateStr = offsetDateStr(hkDateStr, -1);
  const taskDate = parseThaiDate(hkDateStr);
  const sourceDate = parseThaiDate(sourceDateStr);
  const storageKey = `hk_tasks_client_${hkDateStr}`;
  const extrasStorageKey = `hk_extras_client_${hkDateStr}`;

  // Shift the calendar's displayed month by delta, clamping the day to the
  // target month's length so the MiniCalendar's ‹ › arrows actually work
  // in the Housekeeping panel (previously wired to no-ops).
  const shiftHkMonth = (delta) => {
    const [d, m, y] = hkDateStr.split('/').map(Number);
    const lastDay = new Date(y, m + delta, 0).getDate();
    setHkDateStr(fmtThaiDate(new Date(y, m - 1 + delta, Math.min(d, lastDay))));
  };

  const roomSort = (a, b) => Number(String(a.room).replace(/\D/g, '')) - Number(String(b.room).replace(/\D/g, ''));
  const isVoidRow = row => !row || row._voided || row.isVoided || row._addon || row._settleOf;
  const occupiedOnSourceDate = row => {
    const ci = row && row.ci ? parseThaiDate(String(row.ci)) : null;
    const co = row && row.co ? parseThaiDate(String(row.co)) : null;
    if (ci && co && !isNaN(ci) && !isNaN(co)) return sourceDate >= ci && sourceDate < co;
    return String(row && row.date || '') === sourceDateStr;
  };

  const soldYesterdayMap = {};
  bookingRows.forEach(row => {
    if (isVoidRow(row) || !row.room || !occupiedOnSourceDate(row)) return;
    const room = String(row.room);
    const existing = soldYesterdayMap[room];
    if (!existing || parseThaiDate(row.ci || row.date) > parseThaiDate(existing.ci || existing.date)) {
      soldYesterdayMap[room] = row;
    }
  });

  // Temporary client adapter only. Backend round must replace this with HK_Tasks
  // read/write and keep task creation generated from Daily Report sold rooms.
  const baseTasks = Object.values(soldYesterdayMap).map(row => {
    const room = String(row.room);
    const ci = row.ci ? parseThaiDate(String(row.ci)) : null;
    const co = row.co ? parseThaiDate(String(row.co)) : null;
    const stayNights = ci && co && !isNaN(ci) && !isNaN(co)
      ? Math.max(1, Math.round((co - ci) / 86400000))
      : 1;
    const stayType = co && !isNaN(co) && co > taskDate ? 'STAYOVER' : 'CHECKOUT';
    return {
      task_id: `${hkDateStr}-${room}`,
      task_date: hkDateStr,
      source_date: sourceDateStr,
      room,
      guest_name: String(row.name || row.guest || row.customer || '').trim(),
      check_in: String(row.ci || ''),
      check_out: String(row.co || ''),
      stay_type: stayType,
      stay_nights: stayNights,
      hk_status: 'WAITING',
      priority: 'NORMAL',
      note: '',
      created_at: '',
      updated_at: '',
      done_at: '',
      done_by: '',
    };
  }).sort(roomSort);

  useEffect(() => {
    try {
      setHkClientState(JSON.parse(localStorage.getItem(storageKey) || '{}') || {});
    } catch (e) {
      setHkClientState({});
    }
  }, [storageKey]);

  useEffect(() => {
    try {
      setHkExtras(JSON.parse(localStorage.getItem(extrasStorageKey) || '[]') || []);
    } catch (e) {
      setHkExtras([]);
    }
  }, [extrasStorageKey]);

  const tasks = baseTasks.map(task => ({ ...task, ...(hkClientState[task.task_id] || {}) }));
  const activeTask = tasks.find(task => task.task_id === hkModalRoom);
  const counts = tasks.reduce((acc, task) => {
    acc.ALL += 1;
    acc[task.hk_status] = (acc[task.hk_status] || 0) + 1;
    acc[task.stay_type] = (acc[task.stay_type] || 0) + 1;
    if (task.priority === 'URGENT') acc.URGENT += 1;
    return acc;
  }, { ALL: 0, WAITING: 0, READY: 0, DONE: 0, SKIP: 0, CHECKOUT: 0, STAYOVER: 0, URGENT: 0 });

  const persistClientState = next => {
    setHkClientState(next);
    try { localStorage.setItem(storageKey, JSON.stringify(next)); } catch (e) {}
  };
  const persistExtras = next => {
    setHkExtras(next);
    try { localStorage.setItem(extrasStorageKey, JSON.stringify(next)); } catch (e) {}
  };
  const updateTask = (taskId, patch, options = {}) => {
    const current = tasks.find(task => task.task_id === taskId);
    if (!current) return;
    const allowedPatch = {};
    if (patch.hk_status) allowedPatch.hk_status = patch.hk_status;
    if (patch.priority) allowedPatch.priority = patch.priority;
    if (patch.note !== undefined) allowedPatch.note = patch.note;
    const next = { ...hkClientState, [taskId]: { ...(hkClientState[taskId] || {}), ...allowedPatch } };
    persistClientState(next);
    if (options.close !== false) setHkModalRoom(null);
  };

  const statusLabel = s => ({ WAITING: 'รอ', READY: 'C/O', DONE: 'เสร็จ', SKIP: 'ข้าม' }[s] || s);
  const stayLabel = s => ({ CHECKOUT: 'C/O', STAYOVER: 'อยู่ต่อ' }[s] || s);
  // FRONT: WAITING=เขียว, READY=เทา, URGENT=แดงทับ (ตาม ref)
  // MAID:  card class ขึ้นกับ section ที่อยู่ ไม่ใช้ statusClass
  const frontCardClass = task => {
    const urgent = task.priority === 'URGENT';
    if (urgent) return 'hk-card hk-urgent';
    if (task.hk_status === 'DONE') return 'hk-card hk-ready-front';
    if (task.hk_status === 'READY') return 'hk-card hk-ready-front';
    if (task.hk_status === 'SKIP') return 'hk-card hk-skip';
    return 'hk-card hk-active';
  };
  const maidCardClass = (task, section) => {
    const urgent = task.priority === 'URGENT';
    if (urgent) return 'hk-card hk-urgent';
    if (section === 'READY') return 'hk-card hk-active';
    return 'hk-card hk-maid-waiting';
  };
  const sortTasks = list => [...list].sort((a, b) => (b.priority === 'URGENT') - (a.priority === 'URGENT') || roomSort(a, b));
  const frontActiveTasks = sortTasks(tasks.filter(t => t.hk_status !== 'DONE'));
  const doneTasks = sortTasks(tasks.filter(t => t.hk_status === 'DONE'));
  const openExtras = hkExtras.filter(x => x.status !== 'DONE');
  const doneExtras = hkExtras.filter(x => x.status === 'DONE');
  const sourceRooms = tasks.map(t => t.room);
  const addExtra = () => {
    const room = String(hkExtraDraft.room || '').trim();
    const detail = String(hkExtraDraft.detail || '').trim();
    if (!room || !detail) return;
    persistExtras([...hkExtras, {
      id: `HX-${Date.now()}`,
      room,
      detail,
      status: 'OPEN',
      created_at: new Date().toISOString(),
    }]);
    setHkExtraDraft({ room: '', detail: '' });
    setHkExtraOpen(false);
  };
  const setExtraStatus = (id, status) => {
    persistExtras(hkExtras.map(x => x.id === id ? { ...x, status } : x));
  };
  const deleteExtra = id => {
    persistExtras(hkExtras.filter(x => x.id !== id));
  };
  const completeTask = taskId => {
    const current = tasks.find(t => t.task_id === taskId);
    if (!current || current.hk_status !== 'READY') return;
    const next = { ...hkClientState, [taskId]: { ...(hkClientState[taskId] || {}), hk_status: 'DONE', previousStatus: 'READY' } };
    persistClientState(next);
  };
  const restoreTask = taskId => {
    const current = tasks.find(t => t.task_id === taskId);
    if (!current || current.hk_status !== 'DONE') return;
    const prev = (hkClientState[taskId] || {}).previousStatus || 'READY';
    const next = { ...hkClientState, [taskId]: { ...(hkClientState[taskId] || {}), hk_status: prev, previousStatus: '' } };
    persistClientState(next);
  };
  const maidSections = [
    { key: 'READY', title: 'เข้าได้แล้ว', tasks: sortTasks(tasks.filter(t => t.hk_status === 'READY')) },
    { key: 'WAITING', title: 'รอห้องว่าง', tasks: sortTasks(tasks.filter(t => t.hk_status === 'WAITING')) },
    { key: 'DONE', title: 'เสร็จแล้ว', tasks: sortTasks(tasks.filter(t => t.hk_status === 'DONE')) },
    { key: 'SKIP', title: 'ข้าม', tasks: sortTasks(tasks.filter(t => t.hk_status === 'SKIP')) },
  ];
  const summaryItems = [
    ['ALL', 'ห้องวันนี้', counts.ALL],
    ['CHECKOUT', 'C/O', counts.CHECKOUT],
    ['WAITING', 'รอ', counts.WAITING],
    ['DONE', 'เสร็จ', counts.DONE],
  ];
  const renderTaskCard = (task, view = 'front', section = 'WAITING') => {
    const isDone = task.hk_status === 'DONE';
    const cls = (view === 'maid' ? maidCardClass(task, section) : frontCardClass(task)) + (task.note ? ' has-note' : '');
    if (view === 'maid') {
      if (section === 'READY') {
        // tap 1 ครั้ง → DONE ทันที
        return (
          <button key={task.task_id} className={cls} onClick={() => completeTask(task.task_id)}>
            <span className="hk-room-num">{task.room}</span>
            <span className="hk-task-meta">{stayLabel(task.stay_type)}</span>
          </button>
        );
      }
      if (section === 'DONE') {
        // tap → restore ทันที
        return (
          <button key={task.task_id} className={cls} onClick={() => restoreTask(task.task_id)}>
            <span className="hk-room-num">{task.room}</span>
            <span className="hk-task-meta">{stayLabel(task.stay_type)}</span>
          </button>
        );
      }
      // WAITING / SKIP → disabled
      return (
        <button key={task.task_id} className={cls} disabled>
          <span className="hk-room-num">{task.room}</span>
          <span className="hk-task-meta">{stayLabel(task.stay_type)}</span>
        </button>
      );
    }
    // FRONT
    return (
      <button key={task.task_id} className={cls}
        onClick={() => setHkModalRoom(task.task_id)}>
        <span className="hk-room-num">{task.room}</span>
        <span className="hk-task-meta">{stayLabel(task.stay_type)}</span>
      </button>
    );
  };

  // Dashboard-skin variant of frontCardClass: same status→meaning mapping
  // (urgent overrides; READY=light grey; SKIP=dimmed; default WAITING=green),
  // just mapped to .tx-card.hk-skin-* classes instead of .hk-card classes so
  // it visually matches Dashboard's room-card shape without touching any
  // Dashboard status colour (.tx-card.paid/.due/.broken etc. are untouched —
  // these are new, separate classes).
  const frontCardSkinClass = task => {
    const urgent = task.priority === 'URGENT';
    if (urgent) return 'tx-card hk-skin-urgent';
    if (task.hk_status === 'READY') return 'tx-card hk-skin-ready';
    if (task.hk_status === 'SKIP') return 'tx-card hk-skin-skip';
    return 'tx-card hk-skin-waiting';
  };

  // MAID-skin variant: same Dashboard card shape, but coloured by the
  // maid meaning of each status (READY=enterable→green/tappable;
  // WAITING=not vacated yet→grey/disabled; SKIP=dimmed; URGENT overrides).
  // Mirrors maidCardClass one-to-one, mapped to the .tx-card.hk-skin-*
  // classes. Adds no new logic — tap handlers reuse completeTask/restoreTask.
  const maidCardSkinClass = task => {
    if (task.priority === 'URGENT') return 'tx-card hk-skin-urgent';
    if (task.hk_status === 'READY') return 'tx-card hk-skin-waiting';
    if (task.hk_status === 'SKIP') return 'tx-card hk-skin-skip';
    return 'tx-card hk-skin-ready';
  };

  // One 41-room card for the dashboard-shell board. FRONT view opens the
  // task modal; MAID view taps complete/restore per existing logic.
  const renderBoardCard = room => {
    const task = tasks.find(t => String(t.room) === String(room));
    if (!task) {
      return (
        <div key={room} className="tx-card-wrap">
          <div className="tx-card hk-skin-vacant">
            <span className="tx-room hk-skin-num">{room}</span>
          </div>
        </div>
      );
    }
    if (hkView === 'maid') {
      if (task.hk_status === 'DONE') {
        return (
          <div key={room} className="tx-card-wrap">
            <button className="tx-card hk-skin-done" onClick={() => restoreTask(task.task_id)}>
              <span className="tx-room hk-skin-num">{room}</span>
              <span className="hk-skin-done-label">เสร็จ</span>
            </button>
          </div>
        );
      }
      const tappable = task.hk_status === 'READY';
      return (
        <div key={room} className="tx-card-wrap">
          <button className={maidCardSkinClass(task) + (task.note ? ' has-note' : '')}
            disabled={!tappable}
            onClick={tappable ? () => completeTask(task.task_id) : undefined}>
            <span className="tx-room hk-skin-num">{task.room}</span>
            <span className="hk-skin-meta">{stayLabel(task.stay_type)}</span>
          </button>
        </div>
      );
    }
    // FRONT
    if (task.hk_status === 'DONE') {
      return (
        <div key={room} className="tx-card-wrap">
          <button className="tx-card hk-skin-done" onClick={() => setHkModalRoom(task.task_id)}>
            <span className="tx-room hk-skin-num">{room}</span>
            <span className="hk-skin-done-label">เสร็จ</span>
          </button>
        </div>
      );
    }
    return (
      <div key={room} className="tx-card-wrap">
        <button className={frontCardSkinClass(task) + (task.note ? ' has-note' : '')}
          onClick={() => setHkModalRoom(task.task_id)}>
          <span className="tx-room hk-skin-num">{task.room}</span>
          <span className="hk-skin-meta">{stayLabel(task.stay_type)}</span>
        </button>
      </div>
    );
  };

  if (cardSkin === 'dashboard') {
    // Old standalone Housekeeping concept: the MAID sees ONLY the day's
    // housekeeping task rooms (state.rooms), never the whole hotel. Tasks are
    // already generated from yesterday's sold/occupied rooms (sourceRooms /
    // baseTasks), so today-only sold rooms and unrelated rooms never appear.
    // FRONT keeps the full board so the desk can manage C/O across all rooms.
    // FRONT: full 41-room board. (MAID uses grouped sections below.)
    const boardRooms = allRooms.length ? allRooms : sourceRooms;
    // MAID left-panel sections — today's HK task rooms grouped by status with
    // the operational labels C/O (READY, tappable) / รอ (WAITING, disabled) /
    // เสร็จ (DONE, restore). Same hk-skin cards + renderBoardCard, so the
    // tappable/disabled/restore behaviour and card theme are unchanged; only
    // the grouping/labelling is new. SKIP rooms (edge case) kept under ข้าม
    // only when present so no task room is ever hidden. C/O and รอ always
    // render (with empty state) so both sections are always visible.
    const maidLeftGroups = [
      { key: 'READY',   title: 'C/O',  always: true,  rooms: sortTasks(tasks.filter(t => t.hk_status === 'READY')).map(t => t.room) },
      { key: 'WAITING', title: 'รอ',   always: true,  rooms: sortTasks(tasks.filter(t => t.hk_status === 'WAITING')).map(t => t.room) },
      { key: 'DONE',    title: 'เสร็จ', always: false, rooms: sortTasks(tasks.filter(t => t.hk_status === 'DONE')).map(t => t.room) },
      { key: 'SKIP',    title: 'ข้าม', always: false, rooms: sortTasks(tasks.filter(t => t.hk_status === 'SKIP')).map(t => t.room) },
    ];
    return (
      <div className="page dash hk-board-page">
        <div className="dash-body hk-board">
          {/* LEFT — Dashboard geometry + Housekeeping logic/colours.
              FRONT: full 41-room board. MAID: only today's HK task rooms. */}
          <div className="dash-left">
            {hkView === 'maid' ? (
              <div className="hk-maid-groups" style={{ height: '100%', overflowY: 'auto', overflowX: 'hidden', display: 'flex', flexDirection: 'column', gap: '14px' }}>
                {maidLeftGroups.filter(g => g.always || g.rooms.length).map(g => (
                  <section key={g.key} className="hk-section hk-maid-group">
                    <div className="hk-section-head">
                      <span>{g.title}</span>
                      <span className="hk-section-count">{g.rooms.length} ห้อง</span>
                    </div>
                    <div className="tx-grid hk-skin-grid hk-maid-section-grid">
                      {g.rooms.length
                        ? g.rooms.map(renderBoardCard)
                        : <div className="hk-empty" style={{ gridColumn: '1 / -1' }}>ไม่มีห้อง</div>}
                    </div>
                  </section>
                ))}
              </div>
            ) : (
              <div className="tx-grid hk-skin-grid">
                {boardRooms.length
                  ? boardRooms.map(renderBoardCard)
                  : <div className="hk-empty" style={{ gridColumn: '1 / -1' }}>ไม่มีงานแม่บ้านวันนี้</div>}
              </div>
            )}
          </div>

          {/* RIGHT — 340px panel: calendar + summary + extras + done */}
          <div className="dash-right hk-board-right">
            {/* 1. Calendar */}
            <MiniCalendar date={hkDateStr} dataDates={[]} onSelect={(d) => setHkDateStr(fmtThaiDate(d instanceof Date ? d : new Date()))} onPrevMonth={() => shiftHkMonth(-1)} onNextMonth={() => shiftHkMonth(1)} onToday={() => setHkDateStr(fmtThaiDate(new Date()))} />

            {/* 2. Role switch — hidden when a maid is logged in; they see MAID only. */}
            {!isMaidUser && (
              <div className="hk-role-switch hk-board-switch" role="group" aria-label="โหมดแม่บ้าน" style={{gridColumn:'1/-1'}}>
                <button className={hkView === 'front' ? 'active' : ''} onClick={() => setHkView('front')}>FRONT</button>
                <button className={hkView === 'maid' ? 'active' : ''} onClick={() => setHkView('maid')}>MAID</button>
              </div>
            )}

            {/* 3. สรุปวันนี้ */}
            <div className="hk-board-summary" style={{gridColumn:'1/-1'}}>
              <div className="hk-board-summary-title">สรุปวันนี้</div>
              <div className="hk-board-summary-grid">
                <div className="hk-sum-card"><span className="hk-sum-num">{counts.ALL}</span><span className="hk-sum-lbl">ห้องวันนี้</span></div>
                <div className="hk-sum-card hk-sum-pending"><span className="hk-sum-num">{counts.WAITING}</span><span className="hk-sum-lbl">ยังไม่เสร็จ</span></div>
                <div className="hk-sum-card hk-sum-co"><span className="hk-sum-num">{(counts.CHECKOUT || 0) + (counts.READY || 0)}</span><span className="hk-sum-lbl">รอ / C/O</span></div>
                <div className="hk-sum-card hk-sum-done"><span className="hk-sum-num">{counts.DONE}</span><span className="hk-sum-lbl">เสร็จแล้ว</span></div>
              </div>
            </div>

            {/* 4. งานเพิ่มเติม (open) — role class drives view-specific spacing:
                MAID = mobile-first (prominent ทำเสร็จ), FRONT = Dashboard
                รายจ่าย right-panel rhythm. Layout/markup logic unchanged. */}
            <section className={'hk-section hk-main-section hk-extra-section ' + (hkView === 'maid' ? 'hk-extra-maid' : 'hk-extra-front')} style={{gridColumn:'1/-1'}}>
              <div className="hk-section-head">
                <span>งานเพิ่มเติม</span>
                <span className="hk-section-count">{openExtras.length} งาน</span>
                <button className="hk-add-extra-btn" type="button" onClick={() => {
                  setHkExtraDraft({ room: '', detail: '' });
                  setHkExtraOpen(true);
                }}>+ เพิ่มงาน</button>
              </div>
              <div className="hk-extra-list">
                {openExtras.length ? openExtras.map(x => (
                  <div key={x.id} className="hk-extra-row">
                    <div className="hk-extra-room">{x.room}</div>
                    <div className="hk-extra-detail">{x.detail}</div>
                    <div className="hk-extra-actions">
                      <button type="button" onClick={() => setExtraStatus(x.id, 'DONE')}>ทำเสร็จ</button>
                      <button type="button" onClick={() => {
                        setHkExtraDraft({ room: x.room, detail: x.detail, id: x.id });
                        setHkExtraOpen(true);
                      }}>แก้ไข</button>
                      <button type="button" className="delete" onClick={() => deleteExtra(x.id)}>ลบ</button>
                    </div>
                  </div>
                )) : <div className="hk-empty">ยังไม่มีงาน</div>}
              </div>
            </section>

            {/* 5. งานเพิ่มเติมที่เสร็จแล้ว */}
            <section className="hk-section hk-main-section hk-front-done-section" style={{gridColumn:'1/-1'}}>
              <div className="hk-section-head">
                <span>งานเพิ่มเติมที่เสร็จแล้ว</span>
                <span className="hk-section-count">{doneExtras.length} รายการ</span>
              </div>
              {!doneExtras.length && <div className="hk-empty">ยังไม่มีรายการ</div>}
              {doneExtras.length > 0 && (
                <div className="hk-extra-list hk-extra-done-list">
                  {doneExtras.map(x => (
                    <div key={x.id} className="hk-extra-row done">
                      <div className="hk-extra-room hk-extra-room-done">{x.room}</div>
                      <div className="hk-extra-detail hk-extra-detail-done">{x.detail}</div>
                      <div className="hk-extra-actions">
                        <button type="button" className="hk-restore-btn" onClick={() => setExtraStatus(x.id, 'OPEN')}>คืนงาน</button>
                      </div>
                    </div>
                  ))}
                </div>
              )}
            </section>
          </div>
        </div>

        {activeTask && (
          <div className="hk-modal-backdrop" onClick={() => setHkModalRoom(null)}>
            <div className="hk-modal" role="dialog" aria-modal="true" onClick={e => e.stopPropagation()}>
              <button className="hk-modal-x" onClick={() => setHkModalRoom(null)}>×</button>
              <p>ห้อง</p>
              <h2>{activeTask.room}</h2>
              <div className="hk-modal-info">{stayLabel(activeTask.stay_type)} · {statusLabel(activeTask.hk_status)}</div>
              <div className="hk-modal-actions">
                {activeTask.hk_status === 'DONE' ? (
                  <button style={{background:'#FACC15',color:'#8a7000',border:0,borderRadius:'12px',minHeight:'44px',padding:'0 14px',fontSize:'15px',fontWeight:900,cursor:'pointer',width:'100%'}} onClick={() => restoreTask(activeTask.task_id)}>คืนงาน</button>
                ) : (
                  <>
                    <button className="hk-action ready" onClick={() => updateTask(activeTask.task_id, { hk_status: activeTask.hk_status === 'READY' ? 'WAITING' : 'READY' })}>
                      {activeTask.hk_status === 'READY' ? 'ยกเลิก C/O' : 'C/O'}
                    </button>
                    <button className="hk-action skip" disabled={activeTask.stay_type !== 'STAYOVER' || Number(activeTask.stay_nights || 1) <= 2} onClick={() => updateTask(activeTask.task_id, { hk_status: 'SKIP' })}>ข้าม</button>
                    <button className="hk-action urgent" onClick={() => updateTask(activeTask.task_id, { priority: activeTask.priority === 'URGENT' ? 'NORMAL' : 'URGENT' })}>
                      {activeTask.priority === 'URGENT' ? 'ยกเลิกด่วน' : 'ด่วน'}
                    </button>
                  </>
                )}
              </div>
              <label className="hk-field">โน้ต
                <input value={activeTask.note || ''} onChange={e => updateTask(activeTask.task_id, { note: e.target.value }, { close: false })} maxLength={80} placeholder="เพิ่มโน้ตสำหรับงานนี้" />
              </label>
            </div>
          </div>
        )}

        {hkExtraOpen && (
          <div className="hk-modal-backdrop" onClick={() => setHkExtraOpen(false)}>
            <div className="hk-modal hk-extra-modal" role="dialog" aria-modal="true" onClick={e => e.stopPropagation()}>
              <button className="hk-modal-x" onClick={() => setHkExtraOpen(false)}>×</button>
              <h2>เพิ่มงาน</h2>
              <label className="hk-field">สถานที่ / เลขห้อง
                <input value={hkExtraDraft.room || ''} onChange={e => setHkExtraDraft(d => ({ ...d, room: e.target.value }))} maxLength={40} placeholder="เช่น ห้อง 301 หรือล็อบบี้" autoFocus />
              </label>
              <label className="hk-field">รายละเอียดงาน
                <input value={hkExtraDraft.detail || ''} onChange={e => setHkExtraDraft(d => ({ ...d, detail: e.target.value }))} maxLength={80} placeholder="ระบุงานที่ต้องทำ" />
              </label>
              <div className="hk-modal-actions">
                <button className="hk-action skip" onClick={() => setHkExtraOpen(false)}>ยกเลิก</button>
                <button className="hk-action ready" onClick={() => {
                  if (hkExtraDraft.id) {
                    persistExtras(hkExtras.map(x => x.id === hkExtraDraft.id ? { ...x, room: hkExtraDraft.room, detail: hkExtraDraft.detail } : x));
                    setHkExtraOpen(false);
                  } else {
                    addExtra();
                  }
                }}>บันทึก</button>
              </div>
            </div>
          </div>
        )}
      </div>
    );
  }

  return (
    <div className={`page list-page hk-page${hkView === 'maid' ? ' hk-maid-active' : ''}`}>
      <div className="page-header hk-page-head">
        {title && <div className="page-title">{title}</div>}
        <div className="hk-head-right">
          {!isMaidUser && (
            <div className="hk-role-switch" role="group" aria-label="โหมดแม่บ้าน">
              <button className={hkView === 'front' ? 'active' : ''} onClick={() => setHkView('front')}>FRONT</button>
              <button className={hkView === 'maid' ? 'active' : ''} onClick={() => setHkView('maid')}>MAID</button>
            </div>
          )}
          <div className="hk-sync-status">พร้อมใช้งาน</div>
        </div>
      </div>

      <div className="hk-body">
        <div className="hk-content">
        {hkView === 'front' ? (
          <div className="hk-front-board">
          <section className="hk-section hk-main-section hk-front-room-section">
            {cardSkin !== 'dashboard' && (
            <div className="hk-section-head">
              <span>งานห้องวันนี้</span>
              <span className="hk-section-count">{frontActiveTasks.length} ห้อง</span>
            </div>
            )}
            {cardSkin === 'dashboard' ? (
              <div className="tx-grid hk-skin-grid">
                {(allRooms.length ? allRooms : sourceRooms).map(room => {
                  const task = tasks.find(t => String(t.room) === String(room));
                  if (!task) {
                    return (
                      <div key={room} className="tx-card-wrap">
                        <div className="tx-card hk-skin-vacant">
                          <span className="tx-room hk-skin-num">{room}</span>
                        </div>
                      </div>
                    );
                  }
                  if (task.hk_status === 'DONE') {
                    return (
                      <div key={room} className="tx-card-wrap">
                        <div className="tx-card hk-skin-done" onClick={() => setActiveTask(task)}>
                          <span className="tx-room hk-skin-num">{room}</span>
                          <span className="hk-skin-done-label">เสร็จ</span>
                        </div>
                      </div>
                    );
                  }
                  return (
                    <div key={room} className="tx-card-wrap">
                      <button className={frontCardSkinClass(task) + (task.note ? ' has-note' : '')}
                        onClick={() => setHkModalRoom(task.task_id)}>
                        <span className="tx-room hk-skin-num">{task.room}</span>
                        <span className="hk-skin-meta">{stayLabel(task.stay_type)}</span>
                      </button>
                    </div>
                  );
                })}
              </div>
            ) : (
            <div className="hk-grid">
              {(allRooms.length ? allRooms : sourceRooms).map(room => {
                const task = tasks.find(t => String(t.room) === String(room));
                if (!task) {
                  return (
                    <div key={room} className="hk-task-card hk-card-vacant">
                      <span className="hk-room-num hk-room-num-vacant">{room}</span>
                    </div>
                  );
                }
                if (task.hk_status === 'DONE') {
                  return (
                    <div key={room} className="hk-task-card hk-card-done" onClick={() => setActiveTask(task)}>
                      <span className="hk-room-num hk-room-num-done">{room}</span>
                      <span className="hk-task-done-label">เสร็จ</span>
                    </div>
                  );
                }
                return renderTaskCard(task, 'front');
              })}
            </div>
            )}
          </section>

          <section className="hk-section hk-extra-section hk-extra-front">
            <div className="hk-section-head">
              <span>งานเพิ่มเติม</span>
              <span className="hk-section-count">{openExtras.length} งาน</span>
              <button className="hk-add-extra-btn" type="button" onClick={() => {
                setHkExtraDraft({ room: '', detail: '' });
                setHkExtraOpen(true);
              }} disabled={!sourceRooms.length}>+ เพิ่มงาน</button>
            </div>
            <div className="hk-extra-list">
              {openExtras.length ? openExtras.map(x => (
                <div key={x.id} className="hk-extra-row">
                  <div className="hk-extra-room">{x.room}</div>
                  <div className="hk-extra-detail">{x.detail}</div>
                  <div className="hk-extra-actions">
                    <button type="button" onClick={() => setExtraStatus(x.id, 'DONE')}>ทำเสร็จ</button>
                    <button type="button" onClick={() => {
                      setHkExtraDraft({ room: x.room, detail: x.detail, id: x.id });
                      setHkExtraOpen(true);
                    }}>แก้ไข</button>
                    <button type="button" className="delete" onClick={() => deleteExtra(x.id)}>ลบ</button>
                  </div>
                </div>
              )) : <div className="hk-empty">ยังไม่มีงาน</div>}
            </div>
          </section>

          {doneExtras.length > 0 && (
            <section className="hk-section hk-main-section hk-front-done-section">
              <div className="hk-section-head">
                <span>เสร็จแล้ว</span>
                <span className="hk-section-count">{doneExtras.length} รายการ</span>
              </div>
              <div className="hk-extra-list hk-extra-done-list">
                {doneExtras.map(x => (
                  <div key={x.id} className="hk-extra-row done">
                    <div className="hk-extra-room hk-extra-room-done">{x.room}</div>
                    <div className="hk-extra-detail hk-extra-detail-done">{x.detail}</div>
                    <div className="hk-extra-actions">
                      <button type="button" className="hk-restore-btn" onClick={() => setExtraStatus(x.id, 'OPEN')}>คืนงาน</button>
                    </div>
                  </div>
                ))}
              </div>
            </section>
          )}
        </div>
      ) : (
        <div className="hk-maid-board">
          <section className="hk-section hk-maid-section">
            <div className="hk-cal-stats-grid">
              {summaryItems.map(([key, label, count]) => (
                <div key={key} className={`hk-cal-stat ${key.toLowerCase()}`}>
                  <div className="hk-cal-stat-num">{count}</div>
                  <div className="hk-cal-stat-lbl">{label}</div>
                </div>
              ))}
            </div>
          </section>
          {maidSections.filter(s => s.key !== 'DONE').map(section => (
            <section key={section.key} className={`hk-section hk-maid-section hk-section-${section.key.toLowerCase()}`}>
              <div className="hk-section-head">
                <span>{section.title}</span>
                <span className="hk-section-count">{section.tasks.length}</span>
              </div>
              <div className={section.key === 'READY' ? 'hk-floor-groups' : 'hk-mini-grid'}>
                {section.key === 'READY' ? (() => {
                  if (!section.tasks.length) return <div className="hk-empty">ไม่มี</div>;
                  const floors = {};
                  section.tasks.forEach(t => {
                    const f = String(t.room).charAt(0);
                    if (!floors[f]) floors[f] = [];
                    floors[f].push(t);
                  });
                  return Object.keys(floors).sort().map(f => (
                    <div key={f} className="hk-floor-block">
                      <div className="hk-floor-title">ชั้น {f}</div>
                      <div className="hk-mini-grid">
                        {floors[f].map(task => renderTaskCard(task, 'maid', 'READY'))}
                      </div>
                    </div>
                  ));
                })() : section.tasks.length ? section.tasks.map(task => renderTaskCard(task, 'maid', section.key)) : <div className="hk-empty">ไม่มี</div>}
              </div>
            </section>
          ))}
          <section className="hk-section hk-maid-section hk-extra-section hk-extra-maid">
            <div className="hk-section-head">
              <span>งานเพิ่มเติม</span>
              <span className="hk-section-count">{openExtras.length}</span>
            </div>
            <div className="hk-extra-list">
              {openExtras.length ? openExtras.map(x => (
                <div key={x.id} className="hk-extra-row" onClick={() => setExtraStatus(x.id, 'DONE')} style={{cursor:'pointer'}}>
                  <div className="hk-extra-room">{x.room}</div>
                  <div className="hk-extra-detail">{x.detail}</div>
                </div>
              )) : <div className="hk-empty">ยังไม่มีงาน</div>}
            </div>
          </section>
          <section className="hk-section hk-maid-section">
            <div className="hk-section-head">
              <span>เสร็จแล้ว</span>
              <span className="hk-section-count">{doneTasks.length + doneExtras.length} รายการ</span>
            </div>
            {!doneTasks.length && !doneExtras.length && <div className="hk-empty">ยังไม่มีรายการ</div>}
            {doneTasks.length > 0 && (
              <div className="hk-mini-grid" style={{marginBottom: doneExtras.length ? '8px' : 0}}>
                {sortTasks(doneTasks).map(task => renderTaskCard(task, 'maid', 'DONE'))}
              </div>
            )}
            {doneExtras.length > 0 && (
              <div className="hk-extra-list" style={{marginTop: doneTasks.length ? '8px' : 0}}>
                {doneExtras.map(x => (
                  <div key={x.id} className="hk-extra-row done" onClick={() => setExtraStatus(x.id, 'OPEN')} style={{cursor:'pointer'}}>
                    <div className="hk-extra-room" style={{background:'#c8c7c2',color:'#6a6560'}}>{x.room}</div>
                    <div className="hk-extra-detail" style={{color:'#9a9690'}}>{x.detail}</div>
                  </div>
                ))}
              </div>
            )}
          </section>
        </div>
      )}

      {!tasks.length && (
        <section className="hk-section hk-main-section">
          <div className="hk-empty">ยังไม่มี HK_Tasks สำหรับวันนี้จากห้องขายเมื่อวาน</div>
        </section>
      )}
        </div>
        <div className="hk-right-panel">
          <div className="hk-cal-stats">
            <div className="hk-cal-stats-title">สรุปวันนี้</div>
            <div className="hk-cal-stats-grid">
              {summaryItems.map(([key, label, count]) => (
                <div key={key} className={`hk-cal-stat ${key.toLowerCase()}`}>
                  <div className="hk-cal-stat-num">{count}</div>
                  <div className="hk-cal-stat-lbl">{label}</div>
                </div>
              ))}
            </div>
          </div>
          <MiniCalendar
            date={hkDateStr}
            dataDates={[]}
            onSelect={d => setHkDateStr(fmtThaiDate(d))}
            onPrevMonth={() => {
              const d = parseThaiDate(hkDateStr);
              d.setMonth(d.getMonth() - 1);
              setHkDateStr(fmtThaiDate(d));
            }}
            onNextMonth={() => {
              const d = parseThaiDate(hkDateStr);
              d.setMonth(d.getMonth() + 1);
              setHkDateStr(fmtThaiDate(d));
            }}
            onToday={() => setHkDateStr(fmtThaiDate(new Date()))}
          />
        </div>
      </div>

      {activeTask && (
        <div className="hk-modal-backdrop" onClick={() => setHkModalRoom(null)}>
          <div className="hk-modal" role="dialog" aria-modal="true" onClick={e => e.stopPropagation()}>
            <button className="hk-modal-x" onClick={() => setHkModalRoom(null)}>×</button>
            <p>ห้อง</p>
            <h2>{activeTask.room}</h2>
            <div className="hk-modal-info">{stayLabel(activeTask.stay_type)} · {statusLabel(activeTask.hk_status)}</div>
            <div className="hk-modal-actions">
              {activeTask.hk_status === 'DONE' ? (
                <button style={{background:'#FACC15',color:'#8a7000',border:0,borderRadius:'12px',minHeight:'44px',padding:'0 14px',fontSize:'15px',fontWeight:900,cursor:'pointer',width:'100%'}} onClick={() => restoreTask(activeTask.task_id)}>คืนงาน</button>
              ) : (
                <>
                  <button className="hk-action ready" onClick={() => updateTask(activeTask.task_id, { hk_status: activeTask.hk_status === 'READY' ? 'WAITING' : 'READY' })}>
                    {activeTask.hk_status === 'READY' ? 'ยกเลิก C/O' : 'C/O'}
                  </button>
                  <button className="hk-action skip" disabled={activeTask.stay_type !== 'STAYOVER' || Number(activeTask.stay_nights || 1) <= 2} onClick={() => updateTask(activeTask.task_id, { hk_status: 'SKIP' })}>ข้าม</button>
                  <button className="hk-action urgent" onClick={() => updateTask(activeTask.task_id, { priority: activeTask.priority === 'URGENT' ? 'NORMAL' : 'URGENT' })}>
                    {activeTask.priority === 'URGENT' ? 'ยกเลิกด่วน' : 'ด่วน'}
                  </button>
                </>
              )}
            </div>
            <label className="hk-field">โน้ต
              <input value={activeTask.note || ''} onChange={e => updateTask(activeTask.task_id, { note: e.target.value }, { close: false })} maxLength={80} placeholder="เพิ่มโน้ตสำหรับงานนี้" />
            </label>
          </div>
        </div>
      )}

      {hkExtraOpen && (
        <div className="hk-modal-backdrop" onClick={() => setHkExtraOpen(false)}>
          <div className="hk-modal hk-extra-modal" role="dialog" aria-modal="true" onClick={e => e.stopPropagation()}>
            <button className="hk-modal-x" onClick={() => setHkExtraOpen(false)}>×</button>
            <h2>เพิ่มงาน</h2>
            <label className="hk-field">สถานที่ / เลขห้อง
              <input value={hkExtraDraft.room || ''} onChange={e => setHkExtraDraft(d => ({ ...d, room: e.target.value }))} maxLength={40} placeholder="เช่น ห้อง 301 หรือล็อบบี้" autoFocus />
            </label>
            <label className="hk-field">รายละเอียดงาน
              <input value={hkExtraDraft.detail || ''} onChange={e => setHkExtraDraft(d => ({ ...d, detail: e.target.value }))} maxLength={80} placeholder="ระบุงานที่ต้องทำ" />
            </label>
            <div className="hk-modal-actions">
              <button className="hk-action skip" onClick={() => setHkExtraOpen(false)}>ยกเลิก</button>
              <button className="hk-action ready" onClick={() => {
                if (hkExtraDraft.id) {
                  persistExtras(hkExtras.map(x => x.id === hkExtraDraft.id ? { ...x, room: hkExtraDraft.room, detail: hkExtraDraft.detail } : x));
                  setHkExtraOpen(false);
                } else {
                  addExtra();
                }
              }}>บันทึก</button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

/* ---------------- Income page ---------------- */
/* Designed empty state for a transactions section (replaces the bare
   "ยังไม่มีรายการ" table row so the panel reads as intentional, not raw). */
function TxEmptyState({ kind, onAdd, locked }) {
  const isInv = kind === 'inv';
  return (
    <div className="tx-empty">
      <div className={'tx-empty-badge ' + (isInv ? 'inv' : 'noinv')}>{isInv ? 'INV' : 'NO INV'}</div>
      <div className="tx-empty-title">{isInv ? 'ยังไม่มีรายการใบกำกับ' : 'ยังไม่มีรายการไม่มีใบกำกับ'}</div>
      <div className="tx-empty-hint">รายการที่บันทึกในรอบวันนี้จะแสดงที่นี่</div>
      <button className="tx-empty-add" onClick={() => onAdd(kind)} disabled={locked}>
        {isInv ? '+ เพิ่มใบกำกับ' : '+ เพิ่มรายการ'}
      </button>
    </div>
  );
}

function IncomePage({ invoices, noinvoices, expenses, dataDates, locked, canDelete, dateStr, shiftDay, shiftMonth, selectDate, goToday, times, setTimes, openAdd, openEdit, askDelete, invSummary, noinvSummary, paymentReceivedTotal, cashTotal, expTotal, sendTotal }) {
  const [otaOpen, setOtaOpen] = useS(false);
  // Display-only counts of active (non-addon) rows — no calculation logic changed.
  const invCount = invoices.filter((r) => !r._addon).length;
  const noinvCount = noinvoices.filter((r) => !r._addon).length;
  const invTotal = invoices.filter((r) => !r._addon && !r._voided && !r.isVoided).reduce((s, r) => s + Number(r.recv || 0), 0);
  const noinvTotal = noinvoices.filter((r) => !r._addon && !r._voided && !r.isVoided).reduce((s, r) => s + Number(r.recv || 0), 0);
  const otaSources = new Set(['Agoda', 'Agoda INV', 'Agoda NoINV', 'Ascend', 'Webbeds', 'Booking.com', 'Expedia', 'Ctrip', 'Thai Tour', 'Other']);
  // Tag with _kind so the popup can route each item back to the correct
  // openEdit('inv' | 'noinv', row, srcIdx) — the same flow the tables use.
  const otaPendingRows = [
    ...invoices.map((r) => ({ ...r, _kind: 'inv' })),
    ...noinvoices.map((r) => ({ ...r, _kind: 'noinv' })),
  ].filter((r) =>
    !r._addon && !r._voided && !r.isVoided
    && (!r.date || r.date === dateStr)
    && Number(r.pendingAmount || 0) > 0
    && otaSources.has(String(r.source || ''))
  );
  const otaPendingTotal = otaPendingRows.reduce((s, r) => s + Number(r.pendingAmount || 0), 0);
  const otaCanEdit = !locked || canDelete;
  const settleOtaRow = (row) => {
    if (!otaCanEdit) return;
    setOtaOpen(false);
    openEdit(row._kind || 'inv', row, row._srcIdx ?? 0);
  };
  return (
    <div className="page dash tx-dash-page">
      <div className="dash-body">
        <div className="dash-left tx-left-scroll">
          <div className="tx-panel">
            <div className="tx-panel-section tx-section-inv">
              <div className="tx-section-head">
                <span className="tx-section-name">Invoice</span>
                <span className="tx-section-sub">มีใบกำกับภาษี</span>
                <span className="tx-section-count">{invCount} รายการ</span>
                <button className="tx-section-add" onClick={() => openAdd('inv')} disabled={locked}>+ Invoice</button>
              </div>
              <div className="tx-section-body">
                {invCount > 0
                  ? <InvoiceTable rows={invoices} locked={locked} canDelete={canDelete}
                      onRow={(r, i) => !locked || canDelete ? openEdit('inv', r, r._srcIdx ?? i) : null} onDelete={askDelete} />
                  : <TxEmptyState kind="inv" onAdd={openAdd} locked={locked} />}
              </div>
            </div>
            <div className="tx-panel-section tx-section-noinv">
              <div className="tx-section-head">
                <span className="tx-section-name">No Invoice</span>
                <span className="tx-section-sub">ไม่มีใบกำกับภาษี</span>
                <span className="tx-section-count">{noinvCount} รายการ</span>
                <button className="tx-section-add" onClick={() => openAdd('noinv')} disabled={locked}>+ No Invoice</button>
              </div>
              <div className="tx-section-body">
                {noinvCount > 0
                  ? <NoInvoiceTable rows={noinvoices} locked={locked} canDelete={canDelete}
                      onRow={(r, i) => !locked || canDelete ? openEdit('noinv', r, r._srcIdx ?? i) : null} onDelete={askDelete} />
                  : <TxEmptyState kind="noinv" onAdd={openAdd} locked={locked} />}
              </div>
            </div>
          </div>
        </div>
        <div className="dash-right">
          <MiniCalendar date={dateStr} dataDates={dataDates || []} onSelect={selectDate}
            onPrevMonth={() => shiftMonth(-1)} onNextMonth={() => shiftMonth(1)} onToday={goToday} />
          <div className="dash-fin-card">
            <div className="dash-fin-title">สรุปการรับเงิน</div>
            <SummaryCards inv={invSummary || {}} noinv={noinvSummary || {}} />
            <div className="dash-fin-divider" />
            <div className="dash-fin-row">
              <span className="dash-fin-lbl">ยอดรับทั้งหมด</span>
              <span className="dash-fin-amt z">{fmt(invTotal + noinvTotal)}</span>
            </div>
            <div className="dash-fin-row cash-row">
              <span className="dash-fin-lbl">เงินสด</span>
              <span className="dash-fin-amt z">{fmt(cashTotal || 0)}</span>
            </div>
          </div>
          <button
            type="button"
            className={'dash-fin-card ota-pending-btn' + (otaPendingRows.length ? '' : ' empty')}
            onClick={() => otaPendingRows.length && setOtaOpen(true)}
            disabled={!otaPendingRows.length}
          >
            <div className="dash-fin-title">OTA Pending</div>
            {otaPendingRows.length ? (
              <React.Fragment>
                <div className="dash-fin-row"><span className="dash-fin-lbl">{otaPendingRows.length} รายการ</span><span className="dash-fin-amt z">{fmt(otaPendingTotal)}</span></div>
                <div className="ota-pending-btn-hint">กดเพื่อดูรายการและชำระ ›</div>
              </React.Fragment>
            ) : (
              <div className="dash-fin-empty">ไม่มี OTA Pending</div>
            )}
          </button>
          <div className={'dash-send-bar' + ((sendTotal ?? 0) < 0 ? ' negative' : '')}>
            <span className="dash-send-lbl">↗ ส่งยอดวันนี้</span>
            <span className="dash-send-amt z">{fmt(sendTotal ?? 0)}</span>
          </div>
        </div>
      </div>
      {otaOpen && (
        <div className="scrim" onMouseDown={(e) => { if (e.target === e.currentTarget) setOtaOpen(false); }}>
          <div className="popup ota-pending-popup" onMouseDown={(e) => e.stopPropagation()}>
            <div className="popup-head">
              <h3>OTA Pending — {otaPendingRows.length} รายการ · {fmt(otaPendingTotal)}</h3>
              <button className="popup-x" onClick={() => setOtaOpen(false)}>×</button>
            </div>
            <div className="ota-pending-list">
              {otaPendingRows.map((row, i) => (
                <button
                  type="button"
                  key={row._id || row.bookingId || i}
                  className="ota-pending-item"
                  onClick={() => settleOtaRow(row)}
                  disabled={!otaCanEdit}
                  title={otaCanEdit ? 'ชำระยอดค้างของรายการนี้' : 'ปิดรอบแล้ว — ต้องปลดล็อกก่อน'}
                >
                  <div className="ota-item-top">
                    <span className="ota-item-source">{row.source || 'OTA'}</span>
                    <span className="ota-item-room mono">ห้อง {row.room || '—'}</span>
                    {row.bookingId && <span className="ota-item-bkid">{row.bookingId}</span>}
                    <span className="ota-item-kind">{row._kind === 'inv' ? 'Invoice' : 'No Invoice'}</span>
                  </div>
                  <div className="ota-item-bottom">
                    <span className="ota-item-guest">{row.guest || '—'}</span>
                    <span className="ota-item-amount mono">{fmt(row.pendingAmount)}</span>
                  </div>
                </button>
              ))}
              {!otaPendingRows.length && (
                <div className="ota-pending-empty">ไม่มี OTA Pending</div>
              )}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

/* ---------------- Expense page ---------------- */
function ExpensePage({ expenses, locked, canDelete, dateStr, shiftDay, goToday, times, setTimes, openAdd, openEdit, askDelete }) {
  const total = expenses.reduce((sum, row) => sum + Number(row.amount || 0), 0);
  return (
    <div className="page list-page">
      <div className="page-header">
        <div className="page-title">รายจ่าย</div>
      </div>
      <div className="action-bar">
        <div className="ab-btns">
          <button className="act red" onClick={() => openAdd('exp')} disabled={locked}>เพิ่มรายจ่าย</button>
        </div>
        <DateTimeBar date={dateStr} onPrev={() => shiftDay(-1)} onNext={() => shiftDay(1)} onToday={goToday} />
      </div>
      <div className="expense-page-list">
        {expenses.map((row, i) => (
          <button className="expense-item-card" key={row._id || i} onClick={() => !locked || canDelete ? openEdit('exp', row, row._srcIdx ?? i) : null}>
            <span className="expense-item-copy"><strong>{row.cat || 'รายจ่าย'}</strong><small>{row.detail || 'ไม่มีรายละเอียด'}</small></span>
            <span className="expense-item-amount mono">{fmt(row.amount)}</span>
          </button>
        ))}
        {!expenses.length && <div className="expense-item-card expense-empty">ยังไม่มีรายจ่าย</div>}
        <div className="expense-page-total"><span>รวมรายจ่าย</span><strong className="mono">{fmt(total)}</strong></div>
      </div>
    </div>
  );
}

/* ---------------- Main app ---------------- */
const parseThaiDate = (s) => { const [d, m, y] = String(s).split('/').map(Number); return new Date(y, m - 1, d); };
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7);

// Resolve stay-chain root ID. Falls back to parent_booking_id for legacy records.
// Cycle detection via visited Set prevents infinite loops from corrupted data.
const resolveChainId = (booking, allBookings, visited = new Set()) => {
  const selfId = String(booking._id ?? '');
  if (booking.stayChainId) return String(booking.stayChainId);
  if (!booking.parent_booking_id) return selfId;
  const parentId = String(booking.parent_booking_id);
  if (visited.has(parentId)) return selfId; // cycle guard
  visited.add(selfId);
  const parent = allBookings.find((r) => String(r._id ?? '') === parentId);
  if (!parent) return parentId;
  return resolveChainId(parent, allBookings, visited);
};
const fmtThaiDate = (dt) => `${String(dt.getDate()).padStart(2, '0')}/${String(dt.getMonth() + 1).padStart(2, '0')}/${dt.getFullYear()}`;
const offsetDateStr = (dateStr, days) => { const d = parseThaiDate(dateStr); d.setDate(d.getDate() + days); return fmtThaiDate(d); };
const derivePaymentStatus = (received, pending) => Number(received) > 0 && Number(pending) > 0 ? 'partial_deposit' : Number(received) > 0 ? 'received' : Number(pending) > 0 ? 'pending_payment' : 'no_payment';
const rangesOverlap = (startA, endA, startB, endB) => startA < endB && endA > startB;

// Pure function: compute the per-room display state (status, tx, stayProgress,
// conditionDuringStay) for a given date. Shared by the live Dashboard render
// AND the snapshot-at-lock mechanism, so a locked historical date's saved
// snapshot is built from the exact same logic the live view would have shown
// at lock time — not a separate, possibly-drifting code path.
function computeRoomCardStates({ allRooms, txByRoom, maint, allBookingRows, selectedDateStr }) {
  const selectedDate = parseThaiDate(selectedDateStr);
  const out = {};
  allRooms.forEach((room) => {
    const k = String(room);
    const st = maint[k];
    const booking = txByRoom[k];
    const status = st ? st.type : (booking ? 'sold' : 'available');
    const tx = st ? st : txByRoom[k];
    let stayProgress = '';
    if (status === 'sold' && tx && tx.ci && tx.co) {
      const chainIdForProgress = String(resolveChainId(tx, allBookingRows));
      const chainRows = allBookingRows.filter((r) =>
        String(r.room) === k
        && !r._voided && !r.isVoided
        && !r._addon && !r._settleOf
        && r.ci && r.co
        && String(resolveChainId(r, allBookingRows)) === chainIdForProgress
      );
      const chainStart = chainRows.reduce((min, r) => {
        const d = parseThaiDate(String(r.ci));
        return (!min || d < min) ? d : min;
      }, null);
      const chainEnd = chainRows.reduce((max, r) => {
        const d = parseThaiDate(String(r.co));
        return (!max || d > max) ? d : max;
      }, null);
      if (chainStart && chainEnd) {
        const totalNights = Math.round((chainEnd - chainStart) / 86400000);
        const currentNight = Math.round((selectedDate - chainStart) / 86400000) + 1;
        if (totalNights > 1 && currentNight >= 1 && currentNight <= totalNights) {
          stayProgress = currentNight + '/' + totalNights;
        }
      }
    } else if ((status === 'broken' || status === 'free') && tx) {
      const start = tx.startDate ? parseThaiDate(String(tx.startDate)) : null;
      if (!start || isNaN(start.getTime())) {
        stayProgress = '1/1';
      } else {
        const rawEnd = tx.endDate ? parseThaiDate(String(tx.endDate)) : null;
        const end = (rawEnd && !isNaN(rawEnd.getTime()) && rawEnd > start)
          ? rawEnd
          : new Date(start.getTime() + 86400000);
        const totalNights = Math.round((end - start) / 86400000);
        const currentNight = Math.min(
          Math.max(1, Math.round((selectedDate - start) / 86400000) + 1),
          totalNights
        );
        stayProgress = totalNights === 1 ? '1/1' : currentNight + '/' + totalNights;
      }
    }
    const conditionDuringStay = !!(st && booking);
    out[k] = { status, tx, stayProgress, conditionDuringStay };
  });
  return out;
}
const loadStored = (key, fallback) => {
  try {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : fallback;
  } catch(e) {
    return fallback;
  }
};

// Generate a payment group code: ciDay + letter (A, B, C…)
// P3: accepts optional ciDate (YYYY-MM-DD) for precise full-date filtering.
// Falls back to day-number-only for old groups that pre-date the ciDate field.
const generateGroupCode = (ciDay, existingGroups, ciDate) => {
  const dayCount = (existingGroups || []).filter((g) =>
    ciDate && g.ciDate ? g.ciDate === ciDate : g.ciDay === ciDay
  ).length;
  return ciDay + String.fromCharCode(65 + Math.min(dayCount, 25));
};

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "dashColor": "#3F73D9",
  "primaryColor": "#3F73D9",
  "expenseColor": "#D65A52"
}/*EDITMODE-END*/;
function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const [authed, setAuthed] = useS(false);
  const [session, setSession] = useS(null); // API session: { role, client, expires_at, session_id }
  const [authChecked, setAuthChecked] = useS(false);
  const [page, setPage] = useS('dashboard');
  // Pending-supply counter drives the NAV "SUPPLY 3" badge. Read once at
  // mount from localStorage; SupplyPage pushes fresh counts up via
  // onRequestsChange so the badge updates as requests arrive/resolve
  // within the same session.
  const [supPending, setSupPending] = useS(() => {
    try { return supPendingCount(); } catch { return 0; }
  });
  const [curDate, setCurDate] = useS(() => parseThaiDate(HD.date));
  const dateStr = fmtThaiDate(curDate);
  const [user, setUser] = useS(HD.user);
  const [pin, setPin] = useS('');
  const [invoices, setInvoices] = useS(() => loadStored('hataara_invoices', HD.invoices));
  const [noinvoices, setNoinvoices] = useS(() => loadStored('hataara_noinvoices', HD.noinvoices));
  const [expenses, setExpenses] = useS(() => loadStored('hataara_expenses', HD.expenses));
  const [maint, setMaint] = useS(() => {
    try {
      const s = localStorage.getItem('hataara_maint');
      if (s) {
        const parsed = JSON.parse(s);
        return Object.fromEntries(Object.entries(parsed).map(([room, value]) => [room, Array.isArray(value) ? value : [value]]));
      }
    } catch(e) {}
    const m = {}; HD.BROKEN.forEach((r) => { m[String(r)] = [{ type: 'broken', reason: 'ซ่อมบำรุงทั่วไป', note: '', _id: uid() }]; }); return m;
  });
  const activeMaint = Object.fromEntries(Object.entries(maint).map(([room, records]) => {
    const list = Array.isArray(records) ? records : [records];
    const active = list.find((rec) => {
      if (rec.status === 'resolved' || rec.resolved) return false;
      const start = rec.startDate ? parseThaiDate(rec.startDate) : null;
      const end = rec.endDate ? parseThaiDate(rec.endDate) : null;
      if (start && !isNaN(start) && curDate < start) return false;
      if (end && !isNaN(end) && curDate >= end) return false;
      return true;
    });
    return [room, active];
  }).filter(([, rec]) => !!rec));
  const [locks, setLocks] = useS(() => loadStored('hataara_locks', {}));
  const [timesByDate, setTimesByDate] = useS(() => {
    try { const s = localStorage.getItem('hataara_times_by_date'); if (s) return JSON.parse(s); } catch(e) {}
    return {};
  });
  const previousDateStr = offsetDateStr(dateStr, -1);
  const times = timesByDate[dateStr] || { start: (timesByDate[previousDateStr] && timesByDate[previousDateStr].end) || (locks[previousDateStr] && locks[previousDateStr].end) || '08:00', end: '' };
  const setTimes = (next) => setTimesByDate((all) => {
    const current = all[dateStr] || times;
    const value = typeof next === 'function' ? next(current) : next;
    return { ...all, [dateStr]: value };
  });
  const [popup, setPopup] = useS(null);
  const [pickedRoom, setPickedRoom] = useS(null);
  const [pickedRooms, setPickedRooms] = useS([]); // Phase 1: multi-room PG selection
  const [audit, setAudit] = useS(() => {
    try { return JSON.parse(localStorage.getItem('hataara_audit') || '[]'); } catch(e) { return []; }
  });
  // Payment Records — separate log, one entry per confirmed payment event.
  // Never written to directly: only confirmPayment() (below) appends to
  // this, and only after window.PaymentEngine.settleStayChainPayment
  // returns ok:true. Persisted the same way as invoices/noinvoices/expenses.
  const [paymentRecords, setPaymentRecords] = useS(() => loadStored('hataara_payment_records', []));
  // Payment Groups — each entry { id, code, ciDay, primaryRoom, rooms[], createdDate }
  // Stored separately so group membership survives independent of individual booking edits.
  const [paymentGroups, setPaymentGroups] = useS(() => loadStored('hataara_payment_groups', []));
  // Tracks the real backend bridge (payment-engine.js, a true ES module
  // import of backend/record-functions.js + backend/validation.js — see
  // that file's own comments). Payment actions must never run before this
  // is confirmed ready, and a clear error must show if it failed to load.
  const [engineReady, setEngineReady] = useS(!!(window.PaymentEngine && window.PaymentEngine.ready));
  const [engineError, setEngineError] = useS((window.PaymentEngine && window.PaymentEngine.error) || null);
  const [paymentBusy, setPaymentBusy] = useS(false);
  const [paymentError, setPaymentError] = useS(null);
  const isPaymentEngineReady = () => !!(window.PaymentEngine
    && window.PaymentEngine.ready === true
    && typeof window.PaymentEngine.createPaymentRecord === 'function'
    && typeof window.PaymentEngine.settleStayChainPayment === 'function');
  useEffect(() => {
    const checkEngine = () => {
      const ready = isPaymentEngineReady();
      setEngineReady(ready);
      setEngineError(ready ? null : ((window.PaymentEngine && window.PaymentEngine.error) || null));
      if (ready) {
        // Clear any engine-related error once the bridge recovers, so a
        // previously-stuck create/payment modal is immediately usable again
        // (item 3). Covers both the "ยังไม่พร้อมใช้งาน" guard message and the
        // "payment engine failed to load" bridge-load error surfaced on save.
        setPaymentError((current) => {
          const s = String(current || '');
          return (s.includes('ระบบรับชำระยังไม่พร้อม') || s.includes('payment engine')) ? null : current;
        });
      }
    };
    checkEngine();
    window.addEventListener('paymentengine:ready', checkEngine);
    // Fallback poll in case the event fires before this listener attaches
    // (e.g. a very fast module load racing the first React render).
    const poll = setInterval(checkEngine, 300);
    return () => { window.removeEventListener('paymentengine:ready', checkEngine); clearInterval(poll); };
  }, []);
  const canDelete = user.role === 'owner';

  useEffect(() => {
    const h = (e) => {
      if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'p') {
        e.preventDefault();
        alert('กรุณาใช้ปุ่ม "พิมพ์ A5" เพื่อพิมพ์รายงาน');
      }
    };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, []);

  const locked = !!locks[dateStr];

  // Guard: check if a payment record's own paymentDate is locked,
  // independent of the currently-viewed dashboard date.
  const isPaymentDateLocked = (pRecord) => {
    if (!pRecord) return false;
    const pDate = pRecord.paymentDate;
    if (!pDate) return false;
    return !!locks[pDate];
  };

  // Keep the standalone prototype usable after refresh. Production persistence
  // continues to flow through the backend adapter below.
  // Maid is locked to Housekeeping. If the current page is anywhere else
  // when a maid session lands, snap back to housekeeping. This is the last
  // line of defense — the bottom tab bar for maids also only shows HK, so
  // this triggers only after a session role change or a bad deep-link.
  useEffect(() => {
    if (user && user.client === 'maid' && page !== 'housekeeping') {
      setPage('housekeeping');
    }
  }, [user, page]);

  // API session bootstrap: on mount, ask the Worker whether the cookie is
  // still valid; if so, seed session + authed so we skip the login screen.
  // Also listen for `dr:auth-required` events from api-client.js (fired on 401
  // during any API call) so an expired session boots the user back to login.
  useEffect(() => {
    let cancelled = false;
    window.API.me()
      .then((s) => {
        if (cancelled) return;
        setSession(s);
        setUser(userFromSession(s));
        setAuthed(true);
      })
      .catch(() => {
        // 401 or network — leave signed out; LoginScreen will handle it
      })
      .finally(() => { if (!cancelled) setAuthChecked(true); });

    const onAuthRequired = () => {
      setAuthed(false);
      setSession(null);
    };
    window.addEventListener('dr:auth-required', onAuthRequired);
    return () => {
      cancelled = true;
      window.removeEventListener('dr:auth-required', onAuthRequired);
    };
  }, []);

  useEffect(() => { try { localStorage.setItem('hataara_invoices', JSON.stringify(invoices)); } catch(e) {} }, [invoices]);
  useEffect(() => { try { localStorage.setItem('hataara_noinvoices', JSON.stringify(noinvoices)); } catch(e) {} }, [noinvoices]);
  useEffect(() => { try { localStorage.setItem('hataara_expenses', JSON.stringify(expenses)); } catch(e) {} }, [expenses]);
  useEffect(() => { try { localStorage.setItem('hataara_locks', JSON.stringify(locks)); } catch(e) {} }, [locks]);
  useEffect(() => { try { localStorage.setItem('hataara_maint', JSON.stringify(maint)); } catch(e) {} }, [maint]);
  useEffect(() => { try { localStorage.setItem('hataara_times_by_date', JSON.stringify(timesByDate)); } catch(e) {} }, [timesByDate]);
  useEffect(() => { try { localStorage.setItem('hataara_audit', JSON.stringify(audit)); } catch(e) {} }, [audit]);
  useEffect(() => { try { localStorage.setItem('hataara_payment_records', JSON.stringify(paymentRecords)); } catch(e) {} }, [paymentRecords]);
  useEffect(() => { try { localStorage.setItem('hataara_payment_groups', JSON.stringify(paymentGroups)); } catch(e) {} }, [paymentGroups]);

  // ---------- API sync layer ----------
  //
  // Reads: on login + date change, GET /state?date=… once and replace the
  // shared slices (invoices, noinvoices, paymentRecords, hkClientState,
  // hkExtras) with server data mapped through window.SyncMap.
  //
  // Writes: for the staff-critical slices (Reservations, HK), a "diff since
  // last sync" effect notices additions/voids/updates and mirrors them to the
  // Worker in the background. It ignores its own hydration (first snapshot
  // after login) via the `hydrationInProgressRef` flag so we don't try to
  // re-POST rows that just arrived from the server.
  //
  // Slices NOT covered here (owner-only in v1) stay localStorage-only:
  //   expenses · locks · timesByDate · audit · maint (legacy) · maintenance_v1
  //   payment_records · payment_groups
  // Those get real sync in a follow-up commit — see docs/DATA_AUDIT.md.

  const [apiHydrated, setApiHydrated] = useS(false);
  const [apiSyncError, setApiSyncError] = useS(null);
  const hydrationInProgressRef = useRef(false);
  const lastSyncedInvoicesRef = useRef([]);
  const lastSyncedNoinvoicesRef = useRef([]);
  const lastSyncedPaymentsRef = useRef([]);

  // App-level slices hydrate here (Reservations + Payments). HK sync lives in
  // HousekeepingPage since hkClientState/hkExtras are scoped to that component
  // — lifting them up would be a bigger refactor than needed for this pass.
  useEffect(() => {
    if (!authed) return;
    if (!window.API || !window.SyncMap) return;
    let cancelled = false;
    hydrationInProgressRef.current = true;
    setApiHydrated(false);
    const iso = curDateToIsoDate(curDate);
    window.API.getState(iso)
      .then((state) => {
        if (cancelled) return;
        const allReservations = (state.reservations || []).map(window.SyncMap.toAppReservation);
        const nextInvoices   = allReservations.filter((r) => r.docType === 'invoice');
        const nextNoinvoices = allReservations.filter((r) => r.docType === 'noinvoice');
        const nextPayments   = (state.payments || []).map(window.SyncMap.toAppPayment);

        setInvoices(nextInvoices);
        setNoinvoices(nextNoinvoices);
        setPaymentRecords(nextPayments);

        // Capture the snapshot the auto-sync effects should treat as
        // "already synced" — first render after hydration must NOT re-post.
        lastSyncedInvoicesRef.current = nextInvoices;
        lastSyncedNoinvoicesRef.current = nextNoinvoices;
        lastSyncedPaymentsRef.current = nextPayments;

        setApiSyncError(null);
        setApiHydrated(true);
      })
      .catch((err) => {
        if (cancelled) return;
        console.error('[sync] hydrate failed', err);
        setApiSyncError(err.code === 'no_session' ? null : (err.message || 'load failed'));
        // Even on error, unblock UI — localStorage fallback still populates
        setApiHydrated(true);
      })
      .finally(() => {
        if (!cancelled) hydrationInProgressRef.current = false;
      });
    return () => { cancelled = true; };
  }, [authed, curDate]);

  // Reservations write-mirror. Compares `invoices`/`noinvoices` (via _id) to
  // the last-synced snapshot and POSTs new rows / marks voids on the server.
  // Skips its work while hydration is in flight to prevent re-posting rows
  // that just landed from GET /state.
  useEffect(() => {
    if (!authed || !apiHydrated) return;
    if (hydrationInProgressRef.current) return;
    if (!window.API || !window.SyncMap) return;
    syncReservationSlice('invoice', invoices, lastSyncedInvoicesRef);
  }, [authed, apiHydrated, invoices]);

  useEffect(() => {
    if (!authed || !apiHydrated) return;
    if (hydrationInProgressRef.current) return;
    if (!window.API || !window.SyncMap) return;
    syncReservationSlice('noinvoice', noinvoices, lastSyncedNoinvoicesRef);
  }, [authed, apiHydrated, noinvoices]);

  // Payments write-mirror. Payment records are effectively append-only: the
  // app only ever adds new confirmed payments via confirmPayment(). We don't
  // PATCH existing ones (corrections are new records) but we do support VOID.
  useEffect(() => {
    if (!authed || !apiHydrated) return;
    if (hydrationInProgressRef.current) return;
    if (!window.API || !window.SyncMap) return;
    syncPaymentsSlice(paymentRecords, lastSyncedPaymentsRef);
  }, [authed, apiHydrated, paymentRecords]);

  // Reservation fields whose local change should trigger a PATCH. Kept narrow:
  // things a booking edit or payment settlement actually touches. `isVoided`
  // is deliberately excluded — void goes through the /void endpoint instead.
  const RES_SYNC_FIELDS = [
    'invNo', 'room', 'guest', 'source', 'bookingId', 'docType',
    'ci', 'co', 'paymentStatus', 'privateOta',
    'recv', 'pending', 'channel', 'qrTime', 'pendingNote', 'pendingSince',
    'invoiceStatus',
  ];
  function reservationFieldsChanged(a, b) {
    for (const f of RES_SYNC_FIELDS) {
      const av = a?.[f] ?? '';
      const bv = b?.[f] ?? '';
      if (String(av) !== String(bv)) return true;
    }
    return false;
  }

  function syncReservationSlice(docType, current, prevRef) {
    const prev = prevRef.current || [];
    const prevById = new Map(prev.map((r) => [r._id, r]));
    // Additions: rows in `current` whose _id we haven't seen before
    const additions = current.filter((r) => r._id && !prevById.has(r._id));
    // Newly-voided: rows that flipped isVoided true this render
    const newlyVoided = current.filter((r) => {
      const p = prevById.get(r._id);
      return p && !p.isVoided && r.isVoided;
    });
    // Updates: same _id, not newly voided, and at least one synced field differs
    const updates = current.filter((r) => {
      if (!r._id || r.isVoided) return false;
      const p = prevById.get(r._id);
      if (!p || p.isVoided) return false;
      return reservationFieldsChanged(p, r);
    });
    prevRef.current = current;
    additions.forEach(async (r) => {
      try {
        const payload = { ...window.SyncMap.toApiReservation({ ...r, docType }), doc_type: docType };
        const created = await window.API.reservations.create(payload);
        // Stitch server PK back onto the local record so future PATCH/VOID can find it
        const serverId = created.reservation_id;
        if (serverId && serverId !== r._id) {
          const upgrade = (list) => list.map((x) => x._id === r._id
            ? { ...x, reservation_id: serverId, version: created.version }
            : x);
          if (docType === 'invoice') setInvoices(upgrade); else setNoinvoices(upgrade);
        }
      } catch (err) {
        console.error('[sync] reservation create failed', err);
        setApiSyncError('sync ล้มเหลว: ' + (err.message || err.code));
      }
    });
    updates.forEach(async (r) => {
      if (!r.reservation_id) return; // hasn't been posted yet — next tick will
      try {
        const patch = { ...window.SyncMap.toApiReservation({ ...r, docType }), doc_type: docType, version: r.version || 1 };
        const patched = await window.API.reservations.patch(r.reservation_id, patch);
        // Bump the local version so subsequent PATCHes don't 409
        if (patched?.version) {
          const upgrade = (list) => list.map((x) => x.reservation_id === r.reservation_id
            ? { ...x, version: patched.version } : x);
          if (docType === 'invoice') setInvoices(upgrade); else setNoinvoices(upgrade);
        }
      } catch (err) {
        console.error('[sync] reservation patch failed', err);
      }
    });
    newlyVoided.forEach(async (r) => {
      if (!r.reservation_id) return; // never made it to server
      try { await window.API.reservations.void(r.reservation_id); }
      catch (err) { console.error('[sync] reservation void failed', err); }
    });
  }

  function syncPaymentsSlice(current, prevRef) {
    const prev = prevRef.current || [];
    const prevById = new Map(prev.map((p) => [p._id, p]));
    const additions = current.filter((p) => p._id && !prevById.has(p._id));
    const newlyVoided = current.filter((p) => {
      const q = prevById.get(p._id);
      return q && !q.isVoided && p.isVoided;
    });
    prevRef.current = current;
    additions.forEach(async (p) => {
      try {
        const payload = window.SyncMap.toApiPayment(p);
        const created = await window.API.payments.create(payload);
        const serverId = created.payment_id;
        if (serverId && serverId !== p._id) {
          setPaymentRecords((list) => list.map((x) => x._id === p._id
            ? { ...x, payment_id: serverId } : x));
        }
      } catch (err) {
        console.error('[sync] payment create failed', err);
        setApiSyncError('sync ล้มเหลว: ' + (err.message || err.code));
      }
    });
    newlyVoided.forEach(async (p) => {
      if (!p.payment_id) return;
      try { await window.API.payments.void(p.payment_id); }
      catch (err) { console.error('[sync] payment void failed', err); }
    });
  }

  // Prototype UI updates local state. Production persistence will use the normal backend in /backend.
  const api = () => Promise.resolve({ ok: true, localOnly: true });

  /* computed summaries — dashboard uses TODAY's entries only; tables show all */
  const isPrivateOta = (r) => !!(r.privateOta || r.paymentStatus === 'private_ota');
  const visibleForRole = (r) => user.role === 'owner' || !isPrivateOta(r);
  const normalPayment = (r) => !isPrivateOta(r);
  const notVoided = (r) => !r._voided && !r.isVoided;
  const todayInv   = invoices.filter((r) => (!r.date || r.date === dateStr) && visibleForRole(r) && notVoided(r));
  const todayNoinv = noinvoices.filter((r) => (!r.date || r.date === dateStr) && visibleForRole(r) && notVoided(r));
  const normalTodayInv = todayInv.filter(normalPayment);
  const normalTodayNoinv = todayNoinv.filter(normalPayment);
  const roleInvoices = invoices.map((r, i) => ({ ...r, _srcIdx: i })).filter(visibleForRole).filter(notVoided);
  const roleNoinvoices = noinvoices.map((r, i) => ({ ...r, _srcIdx: i })).filter(visibleForRole).filter(notVoided);

  // Payment Group display augmentation for Transactions page.
  // Computes group totals from ALL non-voided records (not just today) so that
  // a group spanning multiple dates still shows correct totals.
  // Injects _displayRecv/_displayPending (display-only, never stored) onto each row:
  //   - primary row: group total paid / group total outstanding
  //   - member row:  null / null  (hides amounts — avoids double-counting)
  //   - non-group row: untouched (no _display* fields added)
  const pgAllRows = [...invoices, ...noinvoices].filter(notVoided).filter(normalPayment);
  const pgGroupTotals = {};
  pgAllRows.forEach((r) => {
    if (!r.paymentGroupId) return;
    const gid = String(r.paymentGroupId);
    if (!pgGroupTotals[gid]) pgGroupTotals[gid] = { paid: 0, outstanding: 0 };
    pgGroupTotals[gid].paid += Number(r.recv || 0);
    pgGroupTotals[gid].outstanding += Number(r.pendingAmount || 0);
  });
  const augmentPg = (rows) => rows.map((r) => {
    if (!r.paymentGroupId) return r;
    const g = pgGroupTotals[String(r.paymentGroupId)];
    if (!g) return r;
    const groupStatus = g.outstanding > 0
      ? (g.paid > 0 ? 'partial_deposit' : 'pending_payment')
      : 'received';
    return r.paymentGroupIsPrimary
      ? { ...r, _displayRecv: g.paid, _displayPending: g.outstanding, _displayStatus: groupStatus }
      : { ...r, _displayRecv: null, _displayPending: null, _displayStatus: 'pg_member' };
  });
  const pgRoleInvoices = augmentPg(roleInvoices);
  const pgRoleNoinvoices = augmentPg(roleNoinvoices);
  const todayExp   = expenses.map((r, i) => ({ ...r, _srcIdx: i })).filter((r) => (!r.date || r.date === dateStr) && notVoided(r));
  const emptyPaymentSummary = {
    invoice: { cash: null, qr: null, edc: null, keyin: null },
    noInvoice: { cash: null, qr: null, bbl: null, agp: null },
    total: 0,
    cash: 0,
  };
  const paymentSummary = engineReady && window.PaymentEngine && window.PaymentEngine.summarizePaymentRecords
    ? window.PaymentEngine.summarizePaymentRecords(paymentRecords, dateStr)
    : emptyPaymentSummary;
  // ซื้อบิลเป็นรายได้ที่ปิดจบทันที ไม่ใช่ Charge/Payment lifecycle จึงคงนับจาก
  // transaction โดยตรง ส่วน Charge ปกติทุกรายการด้านล่างมาจาก paymentDate เท่านั้น
  const buyBillToday = [...normalTodayInv, ...normalTodayNoinv].filter((row) => row.buyBillRefPrice || row.buyBillServiceFeeOf);
  const buyBillTotal = buyBillToday.reduce((total, row) => total + Number(row.recv || 0), 0);
  const buyBillCash = buyBillToday.filter((row) => row.channel === 'Cash').reduce((total, row) => total + Number(row.recv || 0), 0);
  const invSummary = paymentSummary.invoice;
  const noinvSummary = paymentSummary.noInvoice;
  const paymentReceivedTotal = paymentSummary.total + buyBillTotal;
  const cashTotal = paymentSummary.cash + buyBillCash;
  const expTotal  = todayExp.reduce((s, r) => s + Number(r.amount || 0), 0);
  const sendTotal = cashTotal - expTotal;
  const dataDates = [...new Set([...invoices, ...noinvoices, ...expenses].filter(notVoided).map((r) => r.date).filter(Boolean))];
  /* สถานะห้องตามช่วง C/I–C/O — ห้องติดแขกจนถึงวัน c/o (ไม่ใช่แค่วันที่จ่ายเงิน) */
  const occupies = (r) => {
    if (!r.room || r._addon || r._settleOf || r._voided || r.isVoided) return false;
    const ci = r.ci ? parseThaiDate(String(r.ci)) : null;
    const co = r.co ? parseThaiDate(String(r.co)) : null;
    if (ci && co && !isNaN(ci) && !isNaN(co)) return curDate >= ci && curDate < co;
    return (r.date || dateStr) === dateStr;
  };
  const protectPrivate = (r) => user.role === 'owner' || !isPrivateOta(r)
    ? r
    : { ...r, guest: '', source: '', channel: '', recv: 0, pendingAmount: 0, _privateHidden: true };
  const occInv   = invoices.map((r, i) => protectPrivate({ ...r, _srcIdx: i })).filter(occupies);
  const occNoinv = noinvoices.map((r, i) => protectPrivate({ ...r, _srcIdx: i })).filter(occupies);
  const usedRooms = [...new Set([...occInv, ...occNoinv].map((r) => Number(r.room)).filter(Boolean))];
  const allRooms = Object.values(HD.ROOMS_BY_FLOOR).flat();
  const stayoverCount = [...occInv, ...occNoinv].filter((r) => !r._addon && !r._settleOf && r.ci && parseThaiDate(r.ci) < curDate).length;
  const preloginSummary = {
    sold: [...occInv, ...occNoinv].length,
    stayover: stayoverCount,
    available: allRooms.filter((r) => !activeMaint[String(r)] && ![...occInv, ...occNoinv].some((x) => String(x.room) === String(r))).length,
    pendingInv: invoices.filter((r) => r.invoiceStatus === 'pending' && notVoided(r)).length,
    broken: Object.values(activeMaint).filter((r) => r.type === 'broken').length,
  };

  // roomStates for HousekeepingPage — read-only, does not affect Dashboard logic
  const hkTxByRoom = {};
  [...occInv, ...occNoinv].forEach(r => {
    const k = String(r.room);
    const prev = hkTxByRoom[k];
    if (!prev || parseThaiDate(r.ci || r.date) > parseThaiDate(prev.ci || prev.date)) hkTxByRoom[k] = r;
  });
  const hkAllBookingRows = [
    ...invoices.map(r => ({ ...r, _kind: 'inv' })),
    ...noinvoices.map(r => ({ ...r, _kind: 'noinv' })),
  ].filter(r => !r._voided && !r.isVoided);
  const roomStates = computeRoomCardStates({
    allRooms,
    txByRoom: hkTxByRoom,
    maint: activeMaint,
    allBookingRows: hkAllBookingRows,
    selectedDateStr: dateStr,
  });

  // Waiting on the API session probe. Very brief in practice, but avoids a
  // login-flash for users whose cookie is still valid.
  if (!authChecked) {
    return <div className="app-loading">กำลังตรวจสอบ session…</div>;
  }
  // No session — surface the API-backed login. On success we get {role, client, …}
  // from the Worker, derive the local user shape, and drop into the app.
  if (!authed) {
    return <LoginScreen onSuccess={(s) => {
      setSession(s);
      setUser(userFromSession(s));
      setAuthed(true);
    }} />;
  }

  /* handlers */
  const shiftDay = (n) => setCurDate((d) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; });
  const shiftMonth = (n) => setCurDate((d) => { const x = new Date(d); x.setMonth(x.getMonth() + n); return x; });
  const goToday = () => setCurDate(new Date());
  const openAdd  = (type) => {
    // Transaction creation obeys the central role/date rule: staff cannot
    // open a new INV/NO-INV for a past selected date (curDate = the C/I the
    // form defaults to here, e.g. on the Transactions page). Owner is exempt.
    // Expenses are unaffected.
    if ((type === 'inv' || type === 'noinv') && !canCreateTransactionForDate(user.code, curDate)) {
      alert(PAST_DATE_STAFF_WARNING);
      return;
    }
    setPickedRoom(null); setPickedRooms([]); setPopup({ type, edit: null });
  };
  // Dashboard room-card creation forces C/I = today (see forceTodayCI/lockStartDate),
  // so it is always a today transaction — allowed for staff. The save-time guard
  // in saveIncome is the final backstop against any backdated create.
  const openAddRoom = (room) => { setPickedRoom(room); setPickedRooms([]); setPopup({ type: 'inv', edit: null, fromRoomCard: true }); };
  const openMaint = (room) => setPopup({ type: 'maint', room, edit: activeMaint[String(room)] || null });
  const openRoomAction = (room, tx, status) => setPopup({ type: 'roomAction', room, tx, status });
  const closeRoom = (room, data) => {
    const start = data.startDate ? parseThaiDate(data.startDate) : parseThaiDate(dateStr);
    const end = data.endDate ? parseThaiDate(data.endDate) : start;
    if (!(start < end)) { alert('วันที่เริ่มต้องอยู่ก่อนวันที่สิ้นสุด'); return; }
    const prev = activeMaint[String(room)];
    const conditionOverlap = (maint[String(room)] || []).some((r) => {
      if (prev && r._id === prev._id) return false;
      if (r.status === 'resolved' || r.resolved) return false;
      const rStart = r.startDate ? parseThaiDate(r.startDate) : null;
      const rEnd = r.endDate ? parseThaiDate(r.endDate) : null;
      return rStart && rEnd && !isNaN(rStart) && !isNaN(rEnd) && start < rEnd && end > rStart;
    });
    if (conditionOverlap) { alert('ช่วงวันที่นี้มีสถานะห้องเดิมอยู่แล้ว'); return; }
    const rec = { ...data, status: 'active', startedAt: dateStr, room: String(room), date: dateStr, _id: (prev && prev._id) || uid() };
    setMaint((m) => {
      const records = m[String(room)] || [];
      return { ...m, [String(room)]: prev ? records.map((r) => r._id === prev._id ? rec : r) : [...records, rec] };
    });
    api('save', { sheet: 'maint', row: rec });
    setPopup(null);
  };
  const reopenRoom = (room) => {
    const rec = activeMaint[String(room)];
    if (!rec || !confirm('นำห้องกลับมาขายตั้งแต่วันที่เลือก และคงประวัติเดิมไว้?')) return;
    const resolved = { ...rec, status: 'resolved', resolvedAt: dateStr, resolvedBy: user.name };
    setMaint((m) => ({ ...m, [String(room)]: (m[String(room)] || []).map((r) => r._id === rec._id ? resolved : r) }));
    addAudit({ action: 'resolve_room_condition', recordId: rec._id, room: String(room), before: rec });
    if (rec && rec._id) api('resolveMaint', { id: rec._id, resolvedAt: dateStr, resolvedBy: user.name });
    setPopup(null);
  };
  const openEdit = (type, row, idx) => { setPickedRoom(null); setPickedRooms([]); setPopup({ type, edit: row, idx }); };
  const openMoveRoom = () => setPopup({ type: 'moveRoom', side: popup.type, edit: popup.edit, idx: popup.idx });
  const openStayover = () => setPopup({ type: 'stayover', side: popup.type, edit: popup.edit, idx: popup.idx });
  // Find the other booking in the same stay chain with an outstanding balance,
  // and open it directly for editing/payment — even on the same day it was
  // created, when it does not yet occupy "today" and so never appears as the
  // Dashboard's active room-card record. Looks across both inv/noinv lists,
  // skips voided/addon/settlement rows, and skips the record currently open.
  const findChainOutstanding = (booking) => {
    const allRows = [
      ...invoices.map((r, i) => ({ ...r, _kind: 'inv', _idx: i })),
      ...noinvoices.map((r, i) => ({ ...r, _kind: 'noinv', _idx: i })),
    ];
    const chainId = resolveChainId(booking, allRows);
    return allRows.find((r) =>
      String(r._id) !== String(booking._id)
      && !r._voided && !r.isVoided && !r._addon && !r._settleOf
      && Number(r.pendingAmount || 0) > 0
      && resolveChainId(r, allRows) === chainId
    ) || null;
  };
  const openChainOutstanding = () => {
    const other = findChainOutstanding(popup.edit);
    if (other) openEdit(other._kind, other, other._idx);
  };
  const addAudit = (entry) => {
    const record = { ...entry, date: dateStr, actor: user.name, timestamp: new Date().toISOString(), _id: uid() };
    setAudit((rows) => [...rows, record]);
    api('audit', { row: record });
  };

  // ── Frontend ↔ backend field adapters ───────────────────────────────────
  // The frontend's reservation/Charge records use _id/room/ci/co; the
  // backend modules (validation.js, record-functions.js — the actual
  // tested source of truth, imported live via payment-engine.js) use
  // reservationId/roomNo/checkIn/checkOut. These two tiny pure functions
  // only rename keys — they contain no business logic of their own, so
  // there is nothing here that could drift out of sync with the backend.
  const toBackendShape = (r) => ({
    ...r,
    reservationId: r._id,
    roomNo: r.room,
    checkIn: r.ci,
    checkOut: r.co,
  });
  const fromBackendShape = (r) => {
    const { reservationId, roomNo, checkIn, checkOut, ...rest } = r;
    return { ...rest, _id: reservationId, room: roomNo, ci: checkIn, co: checkOut };
  };

  // Recalculate booking recv/pendingAmount/paymentStatus from a live payment
  // records array. Called after correction or void so booking state stays
  // consistent without running the full FIFO engine again.
  const recalcBooking = (reservationId, livePRecords) => {
    const allBookings = [...invoices, ...noinvoices];
    const booking = allBookings.find((r) => String(r._id) === String(reservationId));
    if (!booking) return;
    // Prefer booking.totalAmount (explicit room price) when available and valid.
    // Fall back to recv + pendingAmount for older records that pre-date totalAmount field.
    const storedTotal = Number(booking.totalAmount);
    const originalTotal = Number.isFinite(storedTotal) && storedTotal > 0
      ? storedTotal
      : Number(booking.recv || 0) + Number(booking.pendingAmount || 0);
    const newRecv = livePRecords
      .filter((p) => !p.isVoided && !p._voided)
      .reduce((sum, p) => {
        const hasAlloc = Array.isArray(p.allocations) && p.allocations.length > 0;
        if (hasAlloc) {
          const alloc = p.allocations.find((a) => String(a.reservationId) === String(reservationId));
          return alloc ? sum + Number(alloc.amount || 0) : sum;
        }
        return String(p.reservationId) === String(reservationId) ? sum + Number(p.amount || 0) : sum;
      }, 0);
    const newPending = Math.max(0, originalTotal - newRecv);
    const newStatus = derivePaymentStatus(newRecv, newPending);
    const apply = (list) => list.map((r) =>
      String(r._id) === String(reservationId)
        ? { ...r, recv: newRecv, pendingAmount: newPending, paymentStatus: newStatus } : r);
    setInvoices((prev) => apply(prev));
    setNoinvoices((prev) => apply(prev));
  };

  // Helper: calculate recv/pendingAmount/paymentStatus for a booking from live
  // paymentRecords, using an explicit new total amount (for total-price correction).
  // Does NOT call setState — returns a plain object for the caller to merge.
  const calcBookingBalanceFromPayments = (booking, livePRecords, explicitTotalAmount) => {
    const reservationId = booking._id;
    const totalAmount = explicitTotalAmount != null
      ? Number(explicitTotalAmount)
      : Number(booking.recv || 0) + Number(booking.pendingAmount || 0);
    const paidTotal = livePRecords
      .filter((p) => !p.isVoided && !p._voided)
      .reduce((sum, p) => {
        const hasAlloc = Array.isArray(p.allocations) && p.allocations.length > 0;
        if (hasAlloc) {
          const alloc = p.allocations.find((a) => String(a.reservationId) === String(reservationId));
          return alloc ? sum + Number(alloc.amount || 0) : sum;
        }
        return String(p.reservationId) === String(reservationId) ? sum + Number(p.amount || 0) : sum;
      }, 0);
    const pendingAmount = Math.max(0, totalAmount - paidTotal);
    const paymentStatus = paidTotal >= totalAmount && totalAmount > 0 ? 'received'
      : paidTotal > 0 && paidTotal < totalAmount ? 'partial_deposit'
      : paidTotal === 0 && totalAmount > 0 ? 'pending_payment'
      : 'no_payment';
    return { recv: paidTotal, pendingAmount, paymentStatus };
  };

  // Correct amount / channel / qrTime on an existing non-voided payment record
  // before close round. Multi-alloc (cross-night FIFO) payments only allow
  // channel/qrTime edits — amount edit is blocked in the UI for those.
  const correctPaymentRecord = (paymentId, correction, reservationId) => {
    const updated = paymentRecords.map((p) => {
      if (p.paymentId !== paymentId && p._id !== paymentId) return p;
      const next = { ...p };
      if (correction.amount != null) {
        next.amount = Number(correction.amount);
        if (Array.isArray(next.allocations) && next.allocations.length === 1)
          next.allocations = [{ ...next.allocations[0], amount: Number(correction.amount) }];
      }
      if (correction.channel != null) next.channel = correction.channel;
      if (correction.qrTime != null) next.qrTime = correction.qrTime;
      return next;
    });
    setPaymentRecords(updated);
    recalcBooking(reservationId, updated);
    // Mirror channel/qrTime onto the booking record so card labels stay current
    if (correction.channel != null) {
      const applyChannel = (list) => list.map((r) =>
        String(r._id) === String(reservationId)
          ? { ...r, channel: correction.channel,
              qrTime: correction.qrTime != null ? correction.qrTime : r.qrTime } : r);
      setInvoices((prev) => applyChannel(prev));
      setNoinvoices((prev) => applyChannel(prev));
    }
    addAudit({ action: 'correct_payment', paymentId, correction, reservationId });
  };

  // Hard-delete a payment record before close round (Excel-style correction).
  // Recalculates every booking referenced by the payment's allocations.
  const voidPaymentRecord = (paymentId, currentReservationId) => {
    const target = paymentRecords.find((p) => p.paymentId === paymentId || p._id === paymentId);
    const updated = paymentRecords.filter((p) =>
      p.paymentId !== paymentId && p._id !== paymentId);
    setPaymentRecords(updated);
    const affected = new Set();
    if (target) {
      if (target.reservationId) affected.add(String(target.reservationId));
      (target.allocations || []).forEach((a) => { if (a.reservationId) affected.add(String(a.reservationId)); });
    }
    if (!affected.size) affected.add(String(currentReservationId));
    affected.forEach((rid) => recalcBooking(rid, updated));
    addAudit({ action: 'void_payment', paymentId, reservationId: currentReservationId });
  };

  // The single entry point for confirming a payment against an EXISTING
  // Charge. Calls straight through to window.PaymentEngine.settleStayChainPayment
  // — the real, tested backend function (allocateChainPayment +
  // createPaymentRecord), imported live from backend/record-functions.js.
  // No FIFO, validation, duplicate-ID, or rollback logic is reimplemented
  // here: this function only (a) builds the backend-shaped input the real
  // function expects, (b) calls it, and (c) on ok:true ONLY, writes its
  // result back into invoices/noinvoices/paymentRecords. On ok:false,
  // nothing is written anywhere — the existing Charge/payment state is
  // left exactly as it was (atomic, by construction — see Checkpoint 2).
  const confirmPayment = (chargeRecord, payment) => {
    if (!isPaymentEngineReady()) {
      setPaymentError(engineError || (window.PaymentEngine && window.PaymentEngine.error) || 'ระบบรับชำระยังไม่พร้อมใช้งาน กรุณารอสักครู่แล้วลองใหม่');
      return;
    }
    setPaymentBusy(true);
    setPaymentError(null);
    try {
      const allRecords = [
        ...invoices.map((r, i) => ({ ...r, _kind: 'inv', _idx: i })),
        ...noinvoices.map((r, i) => ({ ...r, _kind: 'noinv', _idx: i })),
      ].filter(notVoided);
      const backendReservations = allRecords.map(toBackendShape);
      // IMPORTANT: backendBooking must be the SAME object instance already
      // inside backendReservations, not a second, separately-mapped copy.
      // resolveStayChain (backend/validation.js) deduplicates rows using a
      // Set keyed on object identity, not on reservationId — passing two
      // different object instances that represent the same Charge causes
      // the real chain member (e.g. Night 2) to never be reached, because
      // the duplicate "Night 1 again" consumes its place in the walk.
      // Looking the seed up by id from the already-built array guarantees
      // there is only ever ONE object per Charge in play.
      const backendBooking = backendReservations.find((r) => String(r.reservationId) === String(chargeRecord._id));
      const paymentInput = {
        amount: Number(payment.amount),
        paymentId: 'PAY-' + uid(),
        paymentDate: dateStr,
        channel: payment.channel,
        qrTime: payment.qrTime || '',
        docType: chargeRecord._kind === 'inv' ? 'invoice' : 'no_invoice',
      };
      const result = window.PaymentEngine.settleStayChainPayment(
        backendBooking,
        paymentInput,
        backendReservations,
        paymentRecords,
        user.name,
        new Date().toISOString(),
      );
      if (!result.ok) {
        setPaymentError((result.errors && result.errors[0]) || 'ไม่สามารถบันทึกการชำระเงินได้');
        setPaymentBusy(false);
        return;
      }
      // ok:true — apply updatedRecords (the touched Charges only) back into
      // invoices/noinvoices, matched by _id. Every other record is left
      // completely untouched, exactly as the engine guarantees.
      const updatedById = new Map(result.updatedRecords.map((r) => [String(r.reservationId), fromBackendShape(r)]));
      const updatedCharge = updatedById.get(String(chargeRecord._id));
      const applyUpdates = (list) => list.map((row) => {
        const upd = updatedById.get(String(row._id));
        return upd ? { ...row, recv: upd.recv, receivedAmount: upd.receivedAmount, pendingAmount: upd.pendingAmount, paymentStatus: upd.paymentStatus } : row;
      });
      setInvoices((list) => applyUpdates(list));
      setNoinvoices((list) => applyUpdates(list));
      setPaymentRecords((list) => [...list, { ...result.paymentRecord, _id: uid() }]);
      if (updatedCharge) {
        setPopup((current) => {
          if (!current || !current.edit || String(current.edit._id) !== String(chargeRecord._id)) return current;
          return {
            ...current,
            edit: {
              ...current.edit,
              recv: updatedCharge.recv,
              receivedAmount: updatedCharge.receivedAmount,
              pendingAmount: updatedCharge.pendingAmount,
              paymentStatus: updatedCharge.paymentStatus,
            },
          };
        });
      }
      setPaymentError(null);
      addAudit({ action: 'confirm_payment', paymentId: result.paymentRecord.paymentId, chainId: result.chainId, amount: paymentInput.amount, allocations: result.allocations });
      api('payment', { paymentId: result.paymentRecord.paymentId, amount: paymentInput.amount, channel: payment.channel, qrTime: payment.qrTime || '', date: dateStr, allocations: result.allocations });
      setPaymentBusy(false);
    } catch (err) {
      setPaymentError('เกิดข้อผิดพลาดขณะบันทึกการชำระเงิน: ' + (err && err.message ? err.message : String(err)));
      setPaymentBusy(false);
    }
  };

  // Sum of all confirmed Payment Records against the stay chain a given
  // Charge belongs to — passed into IncomeForm as chainPaidTotal so its
  // Remaining Balance (Total Amount − chainPaidTotal) is always derived
  // from the real payment log, never typed by staff.
  const chainPaidTotalFor = (chargeRecord) => {
    if (!chargeRecord || !chargeRecord._id) return 0;
    const allRecords = [
      ...invoices.map((r, i) => ({ ...r, _kind: 'inv', _idx: i })),
      ...noinvoices.map((r, i) => ({ ...r, _kind: 'noinv', _idx: i })),
    ].filter(notVoided);
    const chainId = resolveChainId(chargeRecord, allRecords);
    const chainReservationIds = new Set(
      allRecords.filter((r) => resolveChainId(r, allRecords) === chainId).map((r) => String(r._id))
    );
    return paymentRecords
      .filter((p) => !p.isVoided)
      .filter((p) => (p.allocations || []).some((a) => chainReservationIds.has(String(a.reservationId))) || chainReservationIds.has(String(p.reservationId)))
      .reduce((sum, p) => {
        const relevantAllocations = (p.allocations || []).filter((a) => chainReservationIds.has(String(a.reservationId)));
        if (relevantAllocations.length) return sum + relevantAllocations.reduce((s, a) => s + Number(a.amount || 0), 0);
        return chainReservationIds.has(String(p.reservationId)) ? sum + Number(p.amount || 0) : sum;
      }, 0);
  };
  // Sum of every Charge's original total amount across the stay chain a
  // given Charge belongs to. A single Charge's "total" is always
  // recv + pendingAmount — confirmPayment only ever moves money from
  // pendingAmount to recv (see applyUpdates above), it never changes
  // their sum — so this stays correct after any number of payments
  // without needing a separately-persisted totalAmount field per Charge.
  // Passed into IncomeForm as chainTotalAmount so Remaining Balance for a
  // multi-Charge stay chain is computed against the WHOLE chain's total
  // (e.g. 750+750=1500), not just the single Charge currently open.
  const chainTotalAmountFor = (chargeRecord) => {
    if (!chargeRecord || !chargeRecord._id) return Number(chargeRecord?.totalAmount || 0);
    const allRecords = [
      ...invoices.map((r, i) => ({ ...r, _kind: 'inv', _idx: i })),
      ...noinvoices.map((r, i) => ({ ...r, _kind: 'noinv', _idx: i })),
    ].filter(notVoided);
    const chainId = resolveChainId(chargeRecord, allRecords);
    return allRecords
      .filter((r) => resolveChainId(r, allRecords) === chainId)
      .reduce((sum, r) => sum + Number(r.recv || 0) + Number(r.pendingAmount || 0), 0);
  };

  // Every completed, non-voided Payment Record belonging to the stay chain
  // a given Charge is part of — for the Payment History card. Matches the
  // same chain-membership logic chainPaidTotalFor already uses (a payment
  // "belongs" to the chain if either its single reservationId, or any of
  // its allocations, points at a reservation currently in this chain), but
  // returns the full records (sorted newest-first by actual payment
  // timestamp) instead of summing a total. One Payment Record here always
  // corresponds to exactly one real payment event — this never duplicates,
  // splits, or re-derives records; it only filters+sorts what already
  // exists in paymentRecords.
  const chainPaymentHistoryFor = (chargeRecord) => {
    if (!chargeRecord || !chargeRecord._id) return [];
    const allRecords = [
      ...invoices.map((r, i) => ({ ...r, _kind: 'inv', _idx: i })),
      ...noinvoices.map((r, i) => ({ ...r, _kind: 'noinv', _idx: i })),
    ].filter(notVoided);
    const chainId = resolveChainId(chargeRecord, allRecords);
    const chainReservationIds = new Set(
      allRecords.filter((r) => resolveChainId(r, allRecords) === chainId).map((r) => String(r._id))
    );
    // Derive booking total for amount-correction validation (recv + pending of chargeRecord)
    const _bookingTotal = Number(chargeRecord.recv || 0) + Number(chargeRecord.pendingAmount || 0);
    // chargeRecord is a Member if it belongs to a Payment Group but is not Primary
    const _chargeIsMember = !!(chargeRecord.paymentGroupId && !chargeRecord.paymentGroupIsPrimary);
    return paymentRecords
      .filter((p) => !p.isVoided)
      .filter((p) => (p.allocations || []).some((a) => chainReservationIds.has(String(a.reservationId))) || chainReservationIds.has(String(p.reservationId)))
      .slice()
      .sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))
      // Inject guard flags: _recordLocked (payment date locked), _isMember (PG member, no correction)
      .map((p) => ({
        ...p,
        _recordLocked: isPaymentDateLocked(p),
        _isMember: _chargeIsMember,
        _bookingTotal,
      }));
  };

  const moveRoom = (newRoom, reason, note) => {
    // Guard: locked date cannot move room
    if (locked) { alert('วันนี้ปิดรอบแล้ว ไม่สามารถย้ายห้องได้'); return; }
    const { side, edit } = popup;
    const oldRoom = String(edit.room);
    const ci = parseThaiDate(edit.ci), co = parseThaiDate(edit.co);

    // Guard: stay-chain sibling check (Option C — block if chain exists)
    const allRows = [...invoices, ...noinvoices];
    const chainId = resolveChainId(edit, allRows);
    const chainSiblings = allRows.filter(notVoided).filter((r) =>
      r._id !== edit._id && !r._addon && !r._settleOf &&
      resolveChainId(r, allRows) === chainId
    );
    if (chainSiblings.length > 0) {
      alert('รายการนี้มีอยู่ต่อ/หลายช่วงวันที่ใน stay chain\nกรุณาจัดการย้ายห้องผ่าน Cloudbeds หรือแยกตรวจสอบก่อน');
      return;
    }

    // Conflict check: new room must be free for the booking date range
    const bookingConflict = allRows.filter(notVoided).some((r) => {
      if (r._id === edit._id || String(r.room) !== String(newRoom) || !r.ci || !r.co) return false;
      return ci < parseThaiDate(r.co) && co > parseThaiDate(r.ci);
    });
    const conditionConflict = (maint[String(newRoom)] || []).some((r) => {
      if (r.status === 'resolved' || r.resolved) return false;
      if (!r.startDate || !r.endDate) return false;
      return ci < parseThaiDate(r.endDate) && co > parseThaiDate(r.startDate);
    });
    if (bookingConflict || conditionConflict) { alert('ห้องใหม่ไม่ว่างตลอดช่วงเข้าพัก'); return; }

    // Build updated booking: change room + record move metadata.
    // All payment/group fields are preserved via spread.
    const movedAt = new Date().toISOString();
    const existingBooking = allRows.find((r) => String(r._id) === String(edit._id)) || {};
    const updated = {
      ...existingBooking,
      room: String(newRoom),
      movedFromRoom: oldRoom,
      movedToRoom: String(newRoom),
      moveReason: reason || '',
      moveNote: note || '',
      movedAt,
    };

    // Update state by _id (safer than idx)
    const setList = side === 'inv' ? setInvoices : setNoinvoices;
    setList((prev) => prev.map((r) => String(r._id) === String(edit._id) ? updated : r));
    addAudit({ action: 'move_room', bookingId: edit._id, fromRoom: oldRoom, toRoom: String(newRoom), reason: reason || '', note: note || '' });
    api('save', { sheet: side, row: updated });

    // If reason is broken: mark old room as maintenance for the booking date range.
    // Inline the maint write (do not call closeRoom — it calls setPopup(null) internally).
    if (reason === 'broken') {
      const maintStartDate = edit.ci; // Thai display date string (dd/mm/yyyy)
      const maintEndDate = edit.co;
      const maintStart = parseThaiDate(maintStartDate);
      const maintEnd = parseThaiDate(maintEndDate);
      if (maintStart < maintEnd) {
        const prevMaint = activeMaint[oldRoom];
        const overlap = (maint[oldRoom] || []).some((r) => {
          if (prevMaint && r._id === prevMaint._id) return false;
          if (r.status === 'resolved' || r.resolved) return false;
          const rS = r.startDate ? parseThaiDate(r.startDate) : null;
          const rE = r.endDate ? parseThaiDate(r.endDate) : null;
          return rS && rE && !isNaN(rS) && !isNaN(rE) && maintStart < rE && maintEnd > rS;
        });
        if (overlap) {
          alert('บันทึกสถานะห้องเสียไม่ได้: ช่วงวันที่นี้มีสถานะห้องเดิมอยู่แล้ว\n(การย้ายห้องสำเร็จแล้ว แต่กรุณาอัปเดตสถานะห้อง ' + oldRoom + ' เอง)');
        } else {
          const rec = {
            type: 'broken',
            reason: 'ย้ายห้อง-ห้องเสีย',
            note: note || '',
            startDate: maintStartDate,
            endDate: maintEndDate,
            status: 'active',
            startedAt: dateStr,
            room: oldRoom,
            date: dateStr,
            _id: (prevMaint && prevMaint._id) || uid(),
          };
          setMaint((m) => {
            const records = m[oldRoom] || [];
            return { ...m, [oldRoom]: prevMaint ? records.map((r) => r._id === prevMaint._id ? rec : r) : [...records, rec] };
          });
          api('save', { sheet: 'maint', row: rec });
        }
      }
    }

    setPopup(null);
  };
  const createStayover = (data) => {
    const { side, edit } = popup;
    const start = parseThaiDate(data.ci), end = parseThaiDate(data.co);
    const allRows = [...invoices, ...noinvoices];
    const chainId = resolveChainId(edit, allRows);
    const bookingConflict = allRows.filter(notVoided).some((r) => {
      if (r._id === edit._id || String(r.room) !== String(edit.room) || !r.ci || !r.co) return false;
      // Skip bookings in the same stay chain (both chain IDs non-empty and equal)
      const rChain = resolveChainId(r, allRows);
      if (chainId && rChain && chainId === rChain) return false;
      return start < parseThaiDate(r.co) && end > parseThaiDate(r.ci);
    });
    const conditionConflict = (maint[String(edit.room)] || []).some((r) => {
      if (r.status === 'resolved' || r.resolved) return false;
      if (!r.startDate || !r.endDate) return false;
      return start < parseThaiDate(r.endDate) && end > parseThaiDate(r.startDate);
    });
    if (bookingConflict || conditionConflict) { alert('ห้องไม่ว่างในช่วงวันที่อยู่ต่อ'); return; }
    const extension = {
      ...edit,
      _id: uid(),
      parent_booking_id: edit._id || edit.bookingId,
      stayChainId: chainId,
      ci: data.ci,
      co: data.co,
      date: dateStr,
      id: side === 'inv' ? '' : undefined,
      invoiceStatus: side === 'inv' ? 'pending' : 'not_required',
      recv: Number(data.paidAmount || 0),
      pendingAmount: Number(data.outstanding || 0),
      channel: Number(data.paidAmount || 0) > 0 ? data.channel : '',
      qrTime: Number(data.paidAmount || 0) > 0 && data.channel === 'QR' ? data.qrTime : '',
      paymentStatus: data.free ? 'no_payment' : derivePaymentStatus(data.paidAmount, data.outstanding),
      complimentaryExtension: !!data.free,
      note: data.reason || edit.note || '',
    };
    delete extension.totalAmount;
    delete extension.paymentMode;
    delete extension._srcIdx; delete extension._kind;
    if (side === 'inv') setInvoices([...invoices, extension]); else setNoinvoices([...noinvoices, extension]);
    addAudit({ action: 'extend_stay', bookingId: extension._id, parent_booking_id: extension.parent_booking_id, room: extension.room, financialStatus: extension.paymentStatus });
    api('save', { sheet: side, row: extension });
    if (extension.recv > 0) api('payment', { bookingId: extension._id, amount: extension.recv, channel: extension.channel, date: dateStr });
    if (extension.pendingAmount > 0) api('outstanding', { bookingId: extension._id, amount: extension.pendingAmount, date: dateStr, status: 'pending_payment' });
    setPopup(null);
  };
  const saveIncome = (newSide, data, options) => {
    // ซื้อบิล: two finished transactions created together. Handled first,
    // before any room/date logic, because this income type has no room,
    // no C/I/C/O, and no stay chain — none of the conflict checks below
    // apply or are even well-defined for it. The INV doc (Reference Room
    // Price, what the customer purchased) and the NO INV doc (the 30%
    // service fee) are written in the SAME state update so they appear
    // together; the fee doc is linked back to the INV doc's real _id via
    // buyBillServiceFeeOf so the relationship is traceable, but this is
    // informational only — it is explicitly NOT a stayChainId/
    // parent_booking_id link, so none of the stay-chain logic anywhere
    // else in this file (resolveChainId, FIFO settlement, conflict
    // skipping) will ever treat these two records as a chain.
    if (options && options.buyBill && data && data.buyBill) {
      const invId = uid();
      const feeId = uid();
      const invDoc = { ...data.invDoc, _id: invId, date: dateStr };
      const feeDoc = { ...data.feeDoc, _id: feeId, date: dateStr, buyBillServiceFeeOf: invId };
      delete invDoc.buyBill;
      setInvoices((list) => [...list, invDoc]);
      setNoinvoices((list) => [...list, feeDoc]);
      addAudit({ action: 'buy_bill', invId, feeId, refPrice: invDoc.buyBillRefPrice, serviceFee: feeDoc.recv });
      api('save', { sheet: 'inv', row: invDoc });
      api('save', { sheet: 'noinv', row: feeDoc });
      if (invDoc.recv > 0) api('payment', { bookingId: invId, amount: invDoc.recv, channel: invDoc.channel, date: dateStr });
      if (feeDoc.recv > 0) api('payment', { bookingId: feeId, amount: feeDoc.recv, channel: feeDoc.channel, date: dateStr });
      setPopup(null);
      return;
    }
    const origSide = popup.type; // side the row currently lives on
    const rest = { ...data };
    delete rest.extraItems;
    // Strip display-only group fields — never persisted
    delete rest._displayRecv;
    delete rest._displayPending;
    delete rest._displayStatus;
    const editing = popup.edit != null && popup.idx != null;
    const nonFinancialOnly = !!(options && options.nonFinancialOnly);
    // Final backstop for the central permission rule: staff can never create
    // a NEW backdated transaction (C/I before today), regardless of entry
    // point. Editing existing records is unaffected; owner is exempt.
    if (!editing && !rest._addon && rest.ci) {
      const ciDate = parseThaiDate(rest.ci);
      if (!isNaN(ciDate) && !canCreateTransactionForDate(user.code, ciDate)) {
        alert(PAST_DATE_STAFF_WARNING);
        return;
      }
    }
    if (!rest._addon && rest.room && rest.ci && rest.co) {
      const start = parseThaiDate(rest.ci);
      const end = parseThaiDate(rest.co);
      const currentId = popup.edit?._id;
      const allRows = [...invoices, ...noinvoices];
      const editChainId = rest.stayChainId || (popup.edit ? resolveChainId(popup.edit, allRows) : '');
      const bookingConflict = allRows.filter(notVoided).some((row) => {
        if (row._addon || row._settleOf || (currentId && row._id === currentId) || String(row.room) !== String(rest.room) || !row.ci || !row.co) return false;
        // Skip bookings in the same stay chain (both IDs non-empty and equal)
        const rowChain = resolveChainId(row, allRows);
        if (editChainId && rowChain && editChainId === rowChain) return false;
        return rangesOverlap(start, end, parseThaiDate(row.ci), parseThaiDate(row.co));
      });
      const roomConditionConflict = (maint[String(rest.room)] || []).some((row) => {
        if (row.status === 'resolved' || row.resolved) return false;
        if (!row.startDate || !row.endDate) return true;
        return rangesOverlap(start, end, parseThaiDate(row.startDate), parseThaiDate(row.endDate));
      });
      if (bookingConflict || roomConditionConflict) {
        alert('ห้องนี้ไม่ว่างในช่วง C/I – C/O ที่เลือก กรุณาเลือกห้องหรือวันที่ใหม่');
        return;
      }
    }

    // nonFinancialOnly: editing an existing Charge's non-money fields only
    // (guest name, note, dates, sales channel, INV number, side). The
    // payment engine (confirmPayment, via window.PaymentEngine) is the
    // ONLY writer of recv/pendingAmount/paymentStatus for an existing
    // Charge — this path must never touch those fields, so Item 14's
    // received/outstanding consistency check does not apply here either
    // (there is no "increase" to check: this path carries no financial
    // change at all).
    if (nonFinancialOnly && editing) {
      // amountEdit: true means the submission came from the total-price-correction path
      // (canEditAmount=true: not Member, not locked). totalAmount is in the payload;
      // recv/pendingAmount/paymentStatus are recalculated here from live paymentRecords
      // to avoid double-counting existing payments.
      const isAmountEdit = !!(options && options.amountEdit);
      const norm = { ...rest, date: rest.date || dateStr, _id: popup.edit._id };
      delete norm.due;
      delete norm.paymentMode;
      delete norm.recv;
      delete norm.pendingAmount;
      delete norm.paymentStatus;
      if (!isAmountEdit) {
        delete norm.totalAmount;
      }
      const editId = popup.edit._id;
      const moveFrom = newSide !== origSide ? origSide : undefined;
      if (newSide !== origSide) {
        // Side-switch: move record from one list to the other.
        // For amountEdit on side-switch, balance is computed against the existing booking
        // found by _id in whichever list it currently lives on.
        const srcList = origSide === 'inv' ? invoices : noinvoices;
        const existingOnSrc = srcList.find((r) => String(r._id) === String(editId)) || {};
        const cleaned = { ...norm };
        if (newSide === 'noinv') delete cleaned.id;
        const rowToAdd = isAmountEdit
          ? (() => {
              const balance = calcBookingBalanceFromPayments(existingOnSrc, paymentRecords, norm.totalAmount);
              return { ...existingOnSrc, ...cleaned, ...balance };
            })()
          : { ...existingOnSrc, ...cleaned };
        if (origSide === 'inv') {
          setInvoices((prev) => prev.filter((r) => String(r._id) !== String(editId)));
          setNoinvoices((prev) => [...prev, rowToAdd]);
        } else {
          setNoinvoices((prev) => prev.filter((r) => String(r._id) !== String(editId)));
          setInvoices((prev) => [...prev, rowToAdd]);
        }
        const sheet = newSide === 'inv' ? 'inv' : 'noinv';
        api('save', { sheet, row: { ...rowToAdd, _moveFrom: moveFrom } });
      } else {
        // Same side: update record in place, matched by _id (safer than popup.idx).
        const list = newSide === 'inv' ? invoices : noinvoices;
        const setList = newSide === 'inv' ? setInvoices : setNoinvoices;
        const existingBooking = list.find((r) => String(r._id) === String(editId)) || list[popup.idx] || {};
        let finalRecord;
        if (isAmountEdit) {
          // Build final object once: merge norm, then overlay recalculated balance.
          // Single setState call — no double-update race.
          const balance = calcBookingBalanceFromPayments(existingBooking, paymentRecords, norm.totalAmount);
          finalRecord = { ...existingBooking, ...norm, ...balance };
        } else {
          finalRecord = { ...existingBooking, ...norm };
        }
        setList((prev) => prev.map((r) => String(r._id) === String(editId) ? finalRecord : r));
        const sheet = newSide === 'inv' ? 'inv' : 'noinv';
        api('save', { sheet, row: { ...finalRecord, _moveFrom: undefined } });
      }
      setPopup(null);
      return;
    }

    const receivedAmount = Number(rest.recv) || 0;
    const pendingAmount = Number(rest.pendingAmount) || 0;
    // Block inconsistent received/outstanding edits (Item 14). When editing an
    // existing record, if the received amount increases, the outstanding
    // amount must decrease by at least that same difference — otherwise the
    // edit would count previously-received money again or leave a stale
    // outstanding balance behind a now-larger received figure. This does not
    // affect brand-new records (nothing "increases" from a blank slate).
    if (editing && popup.edit) {
      const oldReceived = Number(popup.edit.recv) || 0;
      const oldPending = Number(popup.edit.pendingAmount) || 0;
      const receivedIncrease = receivedAmount - oldReceived;
      if (receivedIncrease > 0) {
        const pendingDecrease = oldPending - pendingAmount;
        if (pendingDecrease < receivedIncrease - 0.01) {
          alert('ยอดชำระเพิ่มขึ้น ' + receivedIncrease.toLocaleString('en-US') + ' แต่ยอดค้างลดลงไม่ครบ — ระบบไม่บันทึกเพื่อป้องกันยอดซ้ำหรือยอดค้างเดิมตกค้าง กรุณาตรวจยอดค้างให้ตรงกับยอดที่ชำระเพิ่ม');
          return;
        }
      }
    }
    const derivedPaymentStatus = derivePaymentStatus(receivedAmount, pendingAmount);
    const privateOta = !!(rest.privateOta || rest.paymentStatus === 'private_ota');
    const paymentStatus = derivedPaymentStatus;
    const norm = { ...rest, privateOta, paymentStatus,
      recv: privateOta ? 0 : receivedAmount,
      pendingAmount: privateOta ? 0 : pendingAmount,
      date: rest.date || dateStr, _id: (editing && popup.edit._id) || uid() };
    delete norm.due;
    delete norm.totalAmount;
    delete norm.paymentMode;
    // Handle Payment Group creation or joining for NEW records only.
    // Editing an existing Charge never changes its group membership.
    if (!editing) {
      const pgMode = norm.paymentGroupMode;
      const pgJoinId = norm.paymentGroupJoinId;
      // Phase 2: extract multi-room list; always delete (never persist)
      const pgRooms = Array.isArray(norm.paymentGroupRooms) && norm.paymentGroupRooms.length > 1
        ? norm.paymentGroupRooms : null;
      delete norm.paymentGroupMode;
      delete norm.paymentGroupJoinId;
      delete norm.paymentGroupRooms;

      // ── Phase 2: batch multi-room Payment Group creation ─────────────
      if (pgMode === 'new' && pgRooms) {
        // Primary room (pgRooms[0]) conflict already checked above.
        // Validate member rooms (pgRooms[1..]) now:
        const batchAllRows = [...invoices, ...noinvoices];
        const batchStart = parseThaiDate(norm.ci);
        const batchEnd   = parseThaiDate(norm.co);
        for (let ri = 1; ri < pgRooms.length; ri++) {
          const rm = String(pgRooms[ri]);
          const rConflict = batchAllRows.filter(notVoided).some((row) => {
            if (row._addon || row._settleOf || String(row.room) !== rm || !row.ci || !row.co) return false;
            return rangesOverlap(batchStart, batchEnd, parseThaiDate(row.ci), parseThaiDate(row.co));
          });
          const rMaintConflict = (maint[rm] || []).some((row) => {
            if (row.status === 'resolved' || row.resolved) return false;
            if (!row.startDate || !row.endDate) return true;
            return rangesOverlap(batchStart, batchEnd, parseThaiDate(row.startDate), parseThaiDate(row.endDate));
          });
          if (rConflict || rMaintConflict) {
            alert('ห้อง ' + rm + ' ไม่ว่างในช่วง C/I – C/O ที่เลือก กรุณาแก้ไขการเลือกห้อง');
            return;
          }
        }
        // Generate ONE group code and ONE group id
        const bCiParts = norm.ci ? String(norm.ci).split('/') : [];
        const bCiDay = bCiParts.length === 3 ? parseInt(bCiParts[0], 10) : parseInt(dateStr.split('/')[0], 10);
        const bCiDate = bCiParts.length === 3
          ? bCiParts[2] + '-' + bCiParts[1].padStart(2, '0') + '-' + bCiParts[0].padStart(2, '0')
          : null;
        const bGroupId  = uid();
        const bGroupCode = generateGroupCode(bCiDay, paymentGroups, bCiDate);
        // Build primary record — explicitly set payment fields so the saved booking
        // reflects the initial payment immediately. { ...norm } carries recv/pendingAmount/
        // paymentStatus from saveIncome's derived values, but we re-state them explicitly
        // here to guard against any future refactor that might break that derivation.
        const primaryNorm = { ...norm, room: String(pgRooms[0]),
          recv: receivedAmount,
          pendingAmount: pendingAmount,
          paymentStatus: paymentStatus,
          paymentGroupId: bGroupId, paymentGroupCode: bGroupCode, paymentGroupIsPrimary: true };
        // Build member records — no revenue, no INV number
        const memberNorms = pgRooms.slice(1).map((rm) => {
          const mNorm = { ...norm, _id: uid(), room: String(rm),
            recv: 0, pendingAmount: 0, paymentStatus: 'no_payment',
            paymentGroupId: bGroupId, paymentGroupCode: bGroupCode, paymentGroupIsPrimary: false };
          delete mNorm.id;
          return mNorm;
        });
        // Payment record for primary only (if recv > 0)
        let bPaymentRecord = null;
        if (!privateOta && receivedAmount > 0) {
          if (!isPaymentEngineReady()) {
            setPaymentError(engineError || (window.PaymentEngine && window.PaymentEngine.error) || 'ระบบรับชำระยังไม่พร้อมใช้งาน กรุณาลองใหม่');
            return;
          }
          let bPayId;
          do { bPayId = 'PAY-' + uid(); }
          while (paymentRecords.some((pr) => pr.paymentId === bPayId && !pr.isVoided && !pr._voided));
          const bCreatedAt = new Date().toISOString();
          const bPayResult = window.PaymentEngine.createPaymentRecord({
            paymentId: bPayId, reservationId: primaryNorm._id,
            allocations: [{ reservationId: primaryNorm._id, amount: receivedAmount }],
            paymentDate: primaryNorm.date, channel: primaryNorm.channel, amount: receivedAmount,
            docType: newSide === 'inv' ? 'invoice' : 'no_invoice',
            qrTime: primaryNorm.qrTime || '', paymentType: 'initial',
          }, user.name, bCreatedAt);
          if (!bPayResult.ok) {
            setPaymentError((bPayResult.errors && bPayResult.errors[0]) || 'ไม่สามารถสร้างประวัติการชำระเริ่มต้นได้');
            return;
          }
          bPaymentRecord = { ...bPayResult.record, _id: uid() };
        }
        // Commit group entry (once)
        setPaymentGroups((gs) => [...gs, {
          id: bGroupId, code: bGroupCode, ciDay: bCiDay, ciDate: bCiDate,
          primaryRoom: String(pgRooms[0]), rooms: pgRooms.map(String), createdDate: dateStr,
        }]);
        // Atomic state write — all records in ONE push
        const bAllNorms = [primaryNorm, ...memberNorms];
        const bList    = newSide === 'inv' ? invoices : noinvoices;
        const bSetList = newSide === 'inv' ? setInvoices : setNoinvoices;
        bSetList([...bList, ...bAllNorms]);
        // API saves per record, payment only for primary
        const bSheet = newSide === 'inv' ? 'inv' : 'noinv';
        bAllNorms.forEach((r) => api('save', { sheet: bSheet, row: r }));
        if (bPaymentRecord) {
          setPaymentRecords((recs) => [...recs, bPaymentRecord]);
          api('payment', { bookingId: primaryNorm._id, amount: receivedAmount, channel: primaryNorm.channel, qrTime: primaryNorm.qrTime || '', date: primaryNorm.date });
        }
        if (pendingAmount > 0) {
          api('outstanding', { bookingId: primaryNorm._id, amount: pendingAmount, date: primaryNorm.date, status: 'pending_payment' });
        }
        addAudit({ action: 'create_pg_batch', groupId: bGroupId, code: bGroupCode, rooms: pgRooms });
        setPopup(null);
        return;
      }
      // ── End Phase 2 batch path ────────────────────────────────────────

      if (pgMode === 'new') {
        // P3: parse ciDate (YYYY-MM-DD) from stored DD/MM/YYYY for full-date group uniqueness
        const ciParts = norm.ci ? String(norm.ci).split('/') : [];
        const ciDay = ciParts.length === 3
          ? parseInt(ciParts[0], 10)
          : parseInt(dateStr.split('/')[0], 10);
        const ciDate = ciParts.length === 3
          ? ciParts[2] + '-' + ciParts[1].padStart(2, '0') + '-' + ciParts[0].padStart(2, '0')
          : null;
        const newGroupId = uid();
        const newGroupCode = generateGroupCode(ciDay, paymentGroups, ciDate);
        norm.paymentGroupId = newGroupId;
        norm.paymentGroupCode = newGroupCode;
        norm.paymentGroupIsPrimary = true;
        setPaymentGroups((gs) => [...gs, {
          id: newGroupId, code: newGroupCode, ciDay, ciDate,
          primaryRoom: String(norm.room), rooms: [String(norm.room)], createdDate: dateStr,
        }]);
      } else if (pgMode === 'join' && pgJoinId) {
        const grp = paymentGroups.find((g) => g.id === pgJoinId);
        if (grp) {
          norm.paymentGroupId = grp.id;
          norm.paymentGroupCode = grp.code;
          norm.paymentGroupIsPrimary = false;
          setPaymentGroups((gs) => gs.map((g) => g.id === grp.id
            ? { ...g, rooms: [...g.rooms, String(norm.room)] }
            : g));
        }
      }
    } else {
      // Editing: strip group mode fields if accidentally present
      delete norm.paymentGroupMode;
      delete norm.paymentGroupJoinId;
      delete norm.paymentGroupRooms; // Phase 2: never persist batch rooms on edit
    }
    // Validate and build the initial Payment Record before writing the new
    // Charge. If the engine is unavailable or rejects the record, neither
    // state collection is changed, preserving an atomic create operation.
    let initialPaymentRecord = null;
    if (!editing && !privateOta && receivedAmount > 0) {
      if (!isPaymentEngineReady()) {
        setPaymentError(engineError || (window.PaymentEngine && window.PaymentEngine.error) || 'ระบบรับชำระยังไม่พร้อมใช้งาน กรุณาลองใหม่');
        return;
      }
      let paymentId;
      do { paymentId = 'PAY-' + uid(); }
      while (paymentRecords.some((payment) => payment.paymentId === paymentId && !payment.isVoided && !payment._voided));
      const createdAt = new Date().toISOString();
      const paymentResult = window.PaymentEngine.createPaymentRecord({
        paymentId,
        reservationId: norm._id,
        allocations: [{ reservationId: norm._id, amount: receivedAmount }],
        paymentDate: norm.date,
        channel: norm.channel,
        amount: receivedAmount,
        docType: newSide === 'inv' ? 'invoice' : 'no_invoice',
        qrTime: norm.qrTime || '',
        paymentType: 'initial',
      }, user.name, createdAt);
      if (!paymentResult.ok) {
        setPaymentError((paymentResult.errors && paymentResult.errors[0]) || 'ไม่สามารถสร้างประวัติการชำระเริ่มต้นได้');
        return;
      }
      initialPaymentRecord = { ...paymentResult.record, _id: uid() };
    }
    if (editing && newSide !== origSide) {
      const cleaned = { ...norm };
      if (newSide === 'noinv') delete cleaned.id;
      if (origSide === 'inv') {
        setInvoices(invoices.filter((_, i) => i !== popup.idx));
        setNoinvoices([...noinvoices, cleaned]);
      } else {
        setNoinvoices(noinvoices.filter((_, i) => i !== popup.idx));
        setInvoices([...invoices, cleaned]);
      }
    } else {
      const list    = newSide === 'inv' ? invoices : noinvoices;
      const setList  = newSide === 'inv' ? setInvoices : setNoinvoices;
      if (editing) {
        const copy = [...list];
        const cleanedExisting = { ...copy[popup.idx] };
        delete cleanedExisting.totalAmount;
        delete cleanedExisting.paymentMode;
        copy[popup.idx] = { ...cleanedExisting, ...norm };
        setList(copy);
      }
      else { setList([...list, norm]); }
    }
    // Queue the record through the normal backend adapter when persistence is connected.
    const sheet = newSide === 'inv' ? 'inv' : 'noinv';
    const moveFrom = editing && newSide !== origSide ? origSide : undefined;
    api('save', { sheet, row: { ...norm, _moveFrom: moveFrom } });
    if (!editing && !privateOta && receivedAmount > 0) {
      setPaymentRecords((records) => [...records, initialPaymentRecord]);
      api('payment', { bookingId: norm._id, amount: receivedAmount, channel: norm.channel, qrTime: norm.qrTime || '', date: norm.date });
    }
    if (!editing && !privateOta && pendingAmount > 0) {
      api('outstanding', { bookingId: norm._id, amount: pendingAmount, date: norm.date, status: 'pending_payment' });
    }
    setPopup(null);
  };
  const saveExpense = (data) => {
    const editing = popup.edit != null && popup.idx != null;
    const norm = { ...data, amount: Number(data.amount) || 0, date: data.date || dateStr, _id: (editing && popup.edit._id) || uid() };
    if (editing) {
      const copy = [...expenses]; copy[popup.idx] = { ...copy[popup.idx], ...norm }; setExpenses(copy);
    } else { setExpenses([...expenses, norm]); }
    api('save', { sheet: 'exp', row: norm });
    setPopup(null);
  };
  const askDelete = (kind, idx) => setPopup({ type: 'del', kind, idx });
  const doDelete  = () => {
    if (locked) { alert('ปิดรอบแล้ว ไม่สามารถลบรายการได้'); setPopup(null); return; }
    const { kind, idx } = popup;
    const row = (kind === 'inv' ? invoices : kind === 'noinv' ? noinvoices : expenses)[idx];
    if (!row) { setPopup(null); return; }
    const voidedAt = new Date().toISOString();
    const voided = { ...row, _voided: true, isVoided: true, _voidedAt: voidedAt, voidedAt, _voidedBy: user.name, voidedBy: user.name };

    // Payment Group: find new primary before any state updates (while lists are still current).
    // Only relevant when the voided row is the primary of a group and other members remain.
    let newPrimaryId = null;
    if ((kind === 'inv' || kind === 'noinv') && row.paymentGroupId && row.paymentGroupIsPrimary) {
      const allCurrent = [...invoices, ...noinvoices];
      const nextPrimary = allCurrent.find(
        (r) => String(r.paymentGroupId) === String(row.paymentGroupId)
          && !r._voided && !r.isVoided
          && String(r._id) !== String(row._id)
      );
      newPrimaryId = nextPrimary ? String(nextPrimary._id) : null;
    }

    // Apply void + optional primary promotion in one pass per list to avoid double-setState.
    const applyVoidAndPromote = (list, listIdx) => list.map((r, i) => {
      if (i === listIdx) return voided;
      if (newPrimaryId && String(r._id) === newPrimaryId) return { ...r, paymentGroupIsPrimary: true };
      return r;
    });

    if (kind === 'inv') {
      setInvoices((list) => applyVoidAndPromote(list, idx));
      // New primary may live in the other list
      if (newPrimaryId) setNoinvoices((list) => list.map((r) =>
        String(r._id) === newPrimaryId ? { ...r, paymentGroupIsPrimary: true } : r));
    }
    if (kind === 'noinv') {
      setNoinvoices((list) => applyVoidAndPromote(list, idx));
      if (newPrimaryId) setInvoices((list) => list.map((r) =>
        String(r._id) === newPrimaryId ? { ...r, paymentGroupIsPrimary: true } : r));
    }
    if (kind === 'exp') { const copy = [...expenses]; copy[idx] = voided; setExpenses(copy); }

    // Update paymentGroups: remove voided room; promote new primary if applicable.
    if ((kind === 'inv' || kind === 'noinv') && row.paymentGroupId) {
      const allCurrent = [...invoices, ...noinvoices];
      const newPrimaryRoom = newPrimaryId
        ? String((allCurrent.find((r) => String(r._id) === newPrimaryId) || {}).room || '')
        : null;
      setPaymentGroups((gs) => gs.map((g) => {
        if (String(g.id) !== String(row.paymentGroupId)) return g;
        const remaining = g.rooms.filter((rm) => rm !== String(row.room));
        return {
          ...g,
          rooms: remaining,
          primaryRoom: (row.paymentGroupIsPrimary && newPrimaryRoom) ? newPrimaryRoom : g.primaryRoom,
        };
      }));
    }

    // Void related paymentRecords so they no longer appear in Dashboard revenue.
    // A record is related if its reservationId matches, or any allocation references row._id.
    if (kind === 'inv' || kind === 'noinv') {
      const payVoidedAt = voidedAt;
      setPaymentRecords((prev) => prev.map((p) => {
        if (p.isVoided || p._voided) return p; // already voided — skip
        const byReservation = String(p.reservationId) === String(row._id);
        const byAllocation = Array.isArray(p.allocations)
          && p.allocations.some((a) => String(a.reservationId) === String(row._id));
        if (!byReservation && !byAllocation) return p;
        return {
          ...p,
          isVoided: true,
          _voided: true,
          voidedAt: payVoidedAt,
          _voidedAt: payVoidedAt,
          voidedReason: 'Transaction deleted',
          voidedReservationId: String(row._id),
        };
      }));
    }

    addAudit({ action: 'void', sheet: kind, recordId: row._id, before: row });
    api('void', { sheet: kind, id: row._id, voidedBy: user.name, voidedAt: voided._voidedAt });
    setPopup(null);
  };

  const sharedProps = { dateStr, shiftDay, shiftMonth, selectDate: setCurDate, goToday, times, setTimes, openAdd, openEdit, askDelete, locked, canDelete };
  const shellStyle = { '--blue': '#3F73D9', '--red': '#D65A52', '--accent': '#3F73D9' };

  return (
    <div className="shell" style={shellStyle}>
      {/* ── MAIN CONTENT ── */}
      <div className="main-content">
        <div className="mc-topbar">
          <nav className="tb-nav">
            {NAV.map((n) => (
              <button key={n.id} className={'tb-nav-item' + (page === n.id ? ' active' : '')} onClick={() => setPage(n.id)} title={n.label}>
                <span className="tb-nav-label">{n.label}</span>
                {n.id === 'supply' && supPending > 0 && (
                  <span className="tb-nav-badge" aria-label={`รอจัดส่ง ${supPending} รายการ`}>{supPending}</span>
                )}
              </button>
            ))}
          </nav>
          <div className="tb-right">
            <div className="mc-user">
              <div className="mc-avatar">{user.name.slice(0, 1)}</div>
              <div className="mc-user-info">
                <span className="mc-user-name">{user.name}</span>
                <span className="mc-user-role">{user.role}</span>
              </div>
            </div>
            {!locked && <button className="tb-icon green" title="ล็อกรอบ"
              onClick={() => { if (!times.end) { alert('กรุณาใส่เวลาจบรอบก่อน'); return; } setPopup({ type: 'lock' }); }}>LOCK</button>}
            {locked && canDelete && <button className="tb-icon" title="ปลดล็อกวันนี้ (เจ้าของเท่านั้น)"
              onClick={() => setPopup({ type: 'unlock' })}>UNLOCK</button>}
            <button className="tb-icon print" title="พิมพ์รายงาน A5 (สรุปยอด)" onClick={() => {
              // Keep window.print() inside the original click gesture. The old
              // setTimeout call could be blocked by browsers before it opened
              // Print Preview. flushSync guarantees the dedicated Dashboard
              // print-report DOM is mounted first when printing from another page.
              if (page !== 'dashboard') ReactDOM.flushSync(() => setPage('dashboard'));
              window.print();
            }}>PRINT</button>
            <button className="tb-icon red" title="ออกจากระบบ" onClick={async () => {
              try { await window.API.logout(); } catch(e) { /* offline is OK — we still clear local state */ }
              setSession(null);
              setAuthed(false);
            }}>LOGOUT</button>
          </div>
        </div>
        {engineError && (
          <div className="engine-error-banner" role="alert">
            ระบบรับชำระเงินไม่สามารถโหลดได้: {engineError} — กรุณารีเฟรชหน้านี้ หากยังไม่หาย กรุณาแจ้งผู้ดูแลระบบ
          </div>
        )}
        {page === 'dashboard' && (
          <DashboardPage invoices={normalTodayInv} noinvoices={normalTodayNoinv}
            allBookings={[
              ...invoices.filter((r) => visibleForRole(r) && notVoided(r) && normalPayment(r)).map((r) => ({ ...r, _kind: 'inv' })),
              ...noinvoices.filter((r) => visibleForRole(r) && notVoided(r) && normalPayment(r)).map((r) => ({ ...r, _kind: 'noinv' })),
            ]}
            expenses={todayExp} dataDates={dataDates}
            occInv={occInv} occNoinv={occNoinv}
            times={times} setTimes={setTimes} invSummary={invSummary} noinvSummary={noinvSummary}
            paymentReceivedTotal={paymentReceivedTotal} cashTotal={cashTotal} expTotal={expTotal} sendTotal={sendTotal}
            dateStr={dateStr} shiftDay={shiftDay} shiftMonth={shiftMonth} selectDate={setCurDate} goToday={goToday}
            locked={locked} canDelete={canDelete} userRole={user.role} openRoomAction={openRoomAction} openEdit={openEdit}
            maint={activeMaint} openMaint={openMaint} roomSnapshot={locks[dateStr] && locks[dateStr].snapshot}
            onAddExpense={() => openAdd('exp')} />
        )}
        {page === 'income' && (
          <IncomePage invoices={pgRoleInvoices} noinvoices={pgRoleNoinvoices} expenses={todayExp} dataDates={dataDates} {...sharedProps}
            invSummary={invSummary} noinvSummary={noinvSummary} paymentReceivedTotal={paymentReceivedTotal} cashTotal={cashTotal} expTotal={expTotal} sendTotal={sendTotal} />
        )}
        {page === 'expense' && (
          <ExpensePage expenses={todayExp} {...sharedProps} />
        )}
        {page === 'housekeeping' && (
          <HousekeepingPage roomStates={roomStates} dateStr={dateStr} bookingRows={hkAllBookingRows} allRooms={Object.values(HD.ROOMS_BY_FLOOR).flat()} cardSkin="dashboard" title="" user={user} />
        )}
        {page === 'maintenance' && (
          <MaintenancePage />
        )}
        {page === 'consignment' && (
          <ConsignmentPage userCode={user.code} userName={user.name} />
        )}
        {page === 'supply' && (
          <SupplyPage userCode={user.code} userName={user.name} onRequestsChange={setSupPending} />
        )}
      </div>

      {/* ── POPUPS ── */}
      {(popup?.type === 'inv' || popup?.type === 'noinv') &&
        <IncomeForm key={popup.edit ? [popup.edit._id, popup.edit.recv, popup.edit.receivedAmount, popup.edit.pendingAmount, popup.edit.paymentStatus].join(':') : 'new'} side={popup.type} edit={popup.edit} draft={popup.draft} pickedRoom={pickedRoom}
          shiftDate={dateStr}
          forceTodayCI={!popup.edit && !!popup.fromRoomCard}
          userRole={user.role}
          lockStartDate={!popup.edit && !!popup.fromRoomCard}
          chainOutstanding={popup.edit ? findChainOutstanding(popup.edit) : null}
          onPayChainOutstanding={openChainOutstanding}
          chainPaidTotal={popup.edit ? chainPaidTotalFor(popup.edit) : 0}
          chainTotalAmount={popup.edit ? chainTotalAmountFor(popup.edit) : 0}
          paymentHistory={popup.edit ? chainPaymentHistoryFor(popup.edit) : []}
          onConfirmPayment={confirmPayment}
          engineReady={engineReady}
          paymentBusy={paymentBusy}
          paymentError={paymentError}
          onClose={() => { setPaymentError(null); setPopup(null); }}
          paymentGroups={paymentGroups}
          pgGroupTotals={pgGroupTotals}
          locked={locked}
          pickedRooms={pickedRooms}
          onCorrectPayment={(paymentId, correction, pRecord) => {
            // Block if dashboard date locked, record's own paymentDate locked, or PG member
            if (locked) return;
            if (pRecord && isPaymentDateLocked(pRecord)) return;
            if (pRecord && pRecord._isMember) return;
            correctPaymentRecord(paymentId, correction, popup.edit?._id);
          }}
          onVoidPayment={(paymentId, pRecord) => {
            // Block if dashboard date locked, record's own paymentDate locked, or PG member
            if (locked) return;
            if (pRecord && isPaymentDateLocked(pRecord)) return;
            if (pRecord && pRecord._isMember) return;
            voidPaymentRecord(paymentId, popup.edit?._id);
          }}
          onPickRoom={(draft, draftSide, opts) => setPopup({ type: 'room',
            _multiSelect: !!(opts && opts.multiSelect),
            _initialSelected: (opts && opts.multiSelect) ? pickedRooms : [],
            _return: { ...popup, type: draftSide, draft } })}
          onRoomStatus={openMaint}
          onMoveRoom={openMoveRoom}
          onStayover={openStayover}
          onDelete={popup.edit != null && !locked ? () => askDelete(popup.type, popup.idx) : undefined}
          onSave={(d, side, options) => saveIncome(side || popup.type, d, options)} />}
      {popup?.type === 'exp' &&
        <ExpenseForm edit={popup.edit} onClose={() => setPopup(null)}
          onDelete={popup.edit != null && !locked ? () => askDelete('exp', popup.idx) : undefined}
          onSave={saveExpense} />}
      {popup?.type === 'quickAdd' &&
        <Scrim onClose={() => setPopup(null)}>
          <div className="popup quick-add-popup" onMouseDown={(e) => e.stopPropagation()}>
            <div className="popup-head"><h3>เพิ่มรายการ</h3><button className="popup-x" aria-label="ปิด" title="ปิด" onClick={() => setPopup(null)}>×</button></div>
            <div className="quick-add-grid">
              <button onClick={() => openAdd('inv')}>INV</button>
              <button onClick={() => openAdd('noinv')}>NO INV</button>
              <button className="expense" onClick={() => openAdd('exp')}>รายจ่าย</button>
            </div>
          </div>
        </Scrim>}
      {popup?.type === 'room' &&
        <RoomPicker usedRooms={usedRooms}
          multiSelect={!!(popup._multiSelect)}
          initialSelected={popup._initialSelected || []}
          onClose={() => setPopup(popup._return)}
          onPick={(rm) => { setPickedRoom(rm); setPickedRooms([]); setPopup(popup._return); }}
          onPickMulti={(rooms) => { setPickedRooms(rooms); setPickedRoom(rooms[0] || null); setPopup(popup._return); }} />}
      {popup?.type === 'roomAction' &&
        <RoomActionPopup room={popup.room}
          onClose={() => setPopup(null)}
          onStay={() => {
            if (popup.status === 'sold' && popup.tx) openEdit(popup.tx._kind, popup.tx, popup.tx._idx);
            else openAddRoom(popup.room);
          }}
          onStatus={() => openMaint(popup.room)} />}
      {popup?.type === 'del' &&
        <DeleteConfirm onClose={() => setPopup(null)} onConfirm={doDelete} />}
      {popup?.type === 'maint' &&
        <MaintenanceForm room={popup.room} edit={popup.edit} shiftDate={dateStr}
          onClose={() => setPopup(null)}
          onSave={(d) => closeRoom(popup.room, d)}
          onReopen={() => reopenRoom(popup.room)} />}
      {popup?.type === 'moveRoom' &&
        <MoveRoomForm booking={popup.edit} rooms={allRooms}
          occupiedRooms={[...occInv, ...occNoinv].filter((r) => r._id !== popup.edit._id).map((r) => String(r.room))}
          maintainedRooms={Object.keys(activeMaint)}
          onClose={() => setPopup({ type: popup.side, edit: popup.edit, idx: popup.idx })}
          onSave={moveRoom} />}
      {popup?.type === 'stayover' &&
        <StayoverForm booking={popup.edit} side={popup.side}
          onClose={() => setPopup({ type: popup.side, edit: popup.edit, idx: popup.idx })}
          onSave={createStayover} />}
      {popup?.type === 'lock' &&
        <LockConfirm start={times.start} end={times.end}
          onClose={() => setPopup(null)}
          onConfirm={() => {
            // Full snapshot-at-lock: capture each room's display state for this
            // date using the exact same logic and data the live Dashboard is
            // showing right now. Stored alongside the lock record so a future
            // visit to this now-closed date renders from this snapshot instead
            // of recomputing live — protecting closed Dashboard history from
            // later payments or edits to the underlying transaction records.
            const snapshotTxByRoom = {};
            const snapshotActiveRows = [
              ...occInv.map((r) => ({ ...r, _kind: 'inv', _idx: r._srcIdx })),
              ...occNoinv.map((r) => ({ ...r, _kind: 'noinv', _idx: r._srcIdx })),
            ];
            snapshotActiveRows.forEach((r) => {
              const k = String(r.room);
              if (!r.room) return;
              const previous = snapshotTxByRoom[k];
              if (!previous || parseThaiDate(r.ci || r.date) > parseThaiDate(previous.ci || previous.date)) snapshotTxByRoom[k] = r;
            });
            const snapshotAllBookingRows = [
              ...invoices.filter((r) => visibleForRole(r) && notVoided(r) && normalPayment(r)).map((r) => ({ ...r, _kind: 'inv' })),
              ...noinvoices.filter((r) => visibleForRole(r) && notVoided(r) && normalPayment(r)).map((r) => ({ ...r, _kind: 'noinv' })),
            ];
            Object.keys(snapshotTxByRoom).forEach((room) => {
              const current = snapshotTxByRoom[room];
              const chainId = resolveChainId(current, snapshotAllBookingRows);
              const chainPendingTotal = snapshotAllBookingRows
                .filter((r) => String(r.room) === room && !r._voided && !r.isVoided
                  && resolveChainId(r, snapshotAllBookingRows) === chainId)
                .reduce((sum, r) => sum + Number(r.pendingAmount || 0), 0);
              if (chainPendingTotal > 0) {
                const chainRecvTotal = snapshotAllBookingRows
                  .filter((r) => String(r.room) === room && !r._voided && !r.isVoided
                    && resolveChainId(r, snapshotAllBookingRows) === chainId)
                  .reduce((sum, r) => sum + Number(r.recv || 0), 0);
                snapshotTxByRoom[room] = {
                  ...current,
                  paymentStatus: chainRecvTotal > 0 ? 'partial_deposit' : 'pending_payment',
                  pendingAmount: chainPendingTotal,
                };
              }
            });
            const snapshot = computeRoomCardStates({
              allRooms, txByRoom: snapshotTxByRoom, maint: activeMaint,
              allBookingRows: snapshotAllBookingRows, selectedDateStr: dateStr,
            });
            api('lockDay', { date: dateStr, start: times.start, end: times.end })
              .then((res) => { if (res.ok) setLocks((l) => ({ ...l, [dateStr]: { start: times.start, end: times.end, snapshot } })); });
            setPopup(null);
          }} />}
      {popup?.type === 'unlock' &&
        <UnlockConfirm date={dateStr}
          onClose={() => setPopup(null)}
          onConfirm={() => {
            api('unlockDay', { date: dateStr })
              .then((res) => { if (res.ok) setLocks((l) => { const c = { ...l }; delete c[dateStr]; return c; }); });
            setPopup(null);
          }} />}

      <TweaksPanel>
        {/* ── TEMPORARY: Void orphan paymentRecords ── remove when done ── */}
        <TweakSection label="🧹 Maintenance (Temp)" />
        <TweakButton label="Void orphan payments" onClick={() => {
          const inv   = JSON.parse(localStorage.getItem('hataara_invoices')        || '[]');
          const noinv = JSON.parse(localStorage.getItem('hataara_noinvoices')      || '[]');
          const activeIds = new Set(
            [...inv, ...noinv].filter(r => !r._voided && !r.isVoided).map(r => String(r._id))
          );
          const isOrphan = (p) =>
            !p.isVoided && !p._voided &&
            !activeIds.has(String(p.reservationId)) &&
            !(Array.isArray(p.allocations) && p.allocations.some(a => activeIds.has(String(a.reservationId))));
          const orphans = paymentRecords.filter(isOrphan);
          if (orphans.length === 0) { alert('ไม่พบ orphan paymentRecords'); return; }
          const total = orphans.reduce((s, p) => s + Number(p.amount || 0), 0);
          const byDate = {};
          orphans.forEach(p => {
            const d = p.paymentDate || 'unknown';
            byDate[d] = (byDate[d] || 0) + Number(p.amount || 0);
          });
          const dateLines = Object.entries(byDate).sort().map(([d, v]) => `  ${d}: ฿${v.toLocaleString('en-US')}`).join('\n');
          const ok = window.confirm(
            `พบ orphan paymentRecords: ${orphans.length} รายการ\nยอดรวม: ฿${total.toLocaleString('en-US')}\n\nจำแนกตามวัน:\n${dateLines}\n\nต้องการ void ทั้งหมดหรือไม่?`
          );
          if (!ok) return;
          const now = new Date().toISOString();
          setPaymentRecords(prev => prev.map(p => {
            if (!isOrphan(p)) return p;
            return { ...p, isVoided: true, _voided: true, voidedAt: now, _voidedAt: now, voidedReason: 'Old orphan paymentRecord cleanup', voidedBy: 'manual-cleanup-button' };
          }));
          alert(`✅ Voided ${orphans.length} orphan records (฿${total.toLocaleString('en-US')})\nกรุณา hard-refresh (Cmd+Shift+R)`);
        }} />
        <TweakSection label="สีเมนู / Nav" />
        <TweakColor label="แดชบอร์ด" value={t.dashColor}
          options={['#3F73D9']}
          onChange={(v) => setTweak('dashColor', v)} />
        <TweakColor label="ธุรกรรม (น้ำเงิน)" value={t.primaryColor}
          options={['#3F73D9']}
          onChange={(v) => setTweak('primaryColor', v)} />
        <TweakColor label="รายจ่าย (แดง)" value={t.expenseColor}
          options={['#D65A52']}
          onChange={(v) => setTweak('expenseColor', v)} />
      </TweaksPanel>

      {/* Mobile bottom tab bar (Option B). Desktop hides it via CSS. Maid
          sees a single-cell strip (HK only); owner + staff (front) see all
          six tabs. Uses the same setPage() as the desktop nav. */}
      {(() => {
        const allowed = allowedTabIdsForUser(user);
        const tabs = NAV.filter((n) => allowed.includes(n.id));
        const onlyHk = tabs.length === 1 && tabs[0].id === 'housekeeping';
        return (
          <nav className={'mobile-tabbar' + (onlyHk ? ' only-hk' : '')}>
            {tabs.map((n) => (
              <button
                key={n.id}
                className={'mtab' + (page === n.id ? ' on' : '')}
                onClick={() => setPage(n.id)}
                aria-label={n.label}>
                <span className="mtab-icon">{n.icon}</span>
                <span className="mtab-label">{n.short}</span>
                {n.id === 'supply' && supPending > 0 && (
                  <span className="mtab-badge">{supPending}</span>
                )}
              </button>
            ))}
          </nav>
        );
      })()}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
