// OP Central — Billing screens: Invoice list/detail, e-Way Bill, Collections, Customer Ledger

// Real taxable/GST for an SO's invoice(s) — summed from the stored, STAMPED
// subtotal/gst on each one (which may carry several different real per-line
// rates once invoiceTaxFromLines is involved — see buildInvoice and
// neighbours), never backed out of the inclusive total by a flat divisor.
// The /1.18 back-calc below only ever applies to a genuinely legacy SO that
// predates the invoice-object system (invoice_no set, invoices empty) and
// so has no stored breakdown to sum in the first place.
function soInvoicedTaxable(so) {
  const invs = so.invoices || [];
  if (invs.length) return invs.reduce((a, i) => a + (i.subtotal || 0), 0);
  return (so.invoice_amount || 0) / 1.18;
}
function soInvoicedGst(so) {
  const invs = so.invoices || [];
  if (invs.length) return invs.reduce((a, i) => a + (i.gst || 0), 0);
  return (so.invoice_amount || 0) - soInvoicedTaxable(so);
}
window.soInvoicedTaxable = soInvoicedTaxable;
window.soInvoicedGst = soInvoicedGst;

function InvoiceList() {
  const { state, navigate, getCustomer } = useStore();
  const invoices = state.sales_orders.filter(s => s.invoice_no);

  return (
    <div className="page">
      <div className="page-header">
        <div>
          <h1 className="page-title">Invoices & e-Way Bills</h1>
          <div className="page-sub">{invoices.length} tax invoices · FY {state.org.fiscal_year}</div>
        </div>
        <div className="page-actions">
          <button className="btn"><Icon name="download" size={13}/>GSTR-1 export</button>
          <button className="btn btn-primary"><Icon name="plus" size={13}/>Raise invoice</button>
        </div>
      </div>

      <div className="kpi-grid mb-3">
        <div className="kpi"><div className="kpi-label">Invoiced MTD</div><div className="kpi-value">{inrK(invoices.reduce((s,i) => s + i.invoice_amount, 0))}</div><div className="kpi-delta"><Delta value={8}/></div></div>
        <div className="kpi"><div className="kpi-label">Output GST</div><div className="kpi-value">{inrK(invoices.reduce((s, so) => s + soInvoicedGst(so), 0))}</div><div className="kpi-delta">May 2026</div></div>
        <div className="kpi"><div className="kpi-label">e-Way Bills</div><div className="kpi-value">{invoices.length}</div><div className="kpi-delta">All active · NIC linked</div></div>
        <div className="kpi"><div className="kpi-label">e-Invoice IRN</div><div className="kpi-value">{invoices.length}</div><div className="kpi-delta">Generated · QR linked</div></div>
      </div>

      <div className="card">
        <div className="filter-bar">
          <input className="input search" placeholder="Search invoice, customer…" style={{ flex: '0 0 220px' }}/>
          <select className="select" style={{ width: 120 }}><option>All customers</option></select>
          <select className="select" style={{ width: 130 }}><option>All FY</option></select>
          <div className="grow"/>
          <span className="muted small">{invoices.length} invoices</span>
        </div>
        <div className="table-wrap">
          <table className="t">
            <thead><tr>
              <th>Invoice No</th><th>Customer</th><th>SO Ref</th><th>Date</th>
              <th className="num">Taxable</th><th className="num">GST</th><th className="num">Total</th>
              <th>EWB</th><th>Status</th><th></th>
            </tr></thead>
            <tbody>
              {invoices.map(so => {
                const c = getCustomer(so.customer_id);
                const taxable = soInvoicedTaxable(so);
                const gst = soInvoicedGst(so);
                return (
                  <tr key={so.id} onClick={() => navigate(`invoices/${so.id}`)} style={{ cursor: 'pointer' }}>
                    <td><a className="mono">{so.invoice_no}</a></td>
                    <td className="trunc">{c.name}</td>
                    <td className="mono small">{so.so_no}</td>
                    <td className="mono small">{fmtDate(so.invoice_date)}</td>
                    <td className="num">{inr(taxable)}</td>
                    <td className="num">{inr(gst)}</td>
                    <td className="num"><strong>{inr(so.invoice_amount)}</strong></td>
                    <td><span className="badge accent dot">EWB-{(231202310000 + parseInt(so.id.replace('so-',''))).toString().slice(0,12)}</span></td>
                    <td>{so.status === 'Payment Pending' ? <span className="badge warning dot">Payment due</span> : <span className="badge success dot">Paid</span>}</td>
                    <td><Icon name="chevronRight" size={12}/></td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

function InvoiceDetail({ soId, invId }) {
  const { state, navigate, getSO, getCustomer, getCategory, getProduct, soSubtotal, soBillAdjustment, soBilledSubtotal } = useStore();
  const so = getSO(soId);
  if (!so) return <div className="page"><div className="empty">{state.loaded ? 'Invoice not found' : 'Loading…'}</div></div>;
  // When an invoice id is given, render THAT specific invoice (partial / consolidated);
  // otherwise fall back to the order-level (legacy) full invoice.
  const inv = invId ? (so.invoices || []).find(i => i.id === invId) : null;
  if (invId && !inv) return <div className="page"><div className="empty">{state.loaded ? 'Invoice not found' : 'Loading…'}</div></div>;
  if (!invId && !so.invoice_no) return <div className="page"><div className="empty">{state.loaded ? 'Invoice not found' : 'Loading…'}</div></div>;
  const c = getCustomer(so.customer_id);
  const sameState = c.state === state.org.state;
  const docNo = inv ? inv.no : so.invoice_no;
  const docDate = inv ? inv.date : so.invoice_date;
  const docType = inv ? (inv.consolidated ? 'Consolidated' : inv.type) : 'Full';
  const docLines = inv ? (inv.lines || []) : null;     // null → render full so.lines
  const orderedSubtotal = soSubtotal(so);
  const billAdj = inv ? 0 : soBillAdjustment(so);
  const subtotal = inv ? (inv.subtotal || 0) : soBilledSubtotal(so);   // billed = ordered − items removed at GRN
  // A STAMPED invoice (inv) is read, never recomputed — the same subtotal
  // and tax this document was actually built and saved with (see
  // buildInvoice and neighbours in this file), so what prints here can never
  // drift from what was stored, even after this fix changes the real rate
  // math. Only the true legacy case (no invoice object at all, so.invoice_no
  // alone) has nothing stored to read, so it computes live from the order's
  // own lines — still the real weighted rate, not a flat 18% guess.
  const legacyPct = inv ? 0 : soWeightedGstPct(so, state);
  const cgst = inv ? (inv.cgst || 0) : (sameState ? subtotal * (legacyPct / 2) / 100 : 0);
  const sgst = inv ? (inv.sgst || 0) : (sameState ? subtotal * (legacyPct / 2) / 100 : 0);
  const igst = inv ? (inv.igst || 0) : (sameState ? 0 : subtotal * legacyPct / 100);
  const grand = inv ? inv.total : subtotal + cgst + sgst + igst;

  // Indian numbers to words (simplified)
  const toWords = (n) => {
    const cr = Math.floor(n / 10000000);
    const lakh = Math.floor((n % 10000000) / 100000);
    const thou = Math.floor((n % 100000) / 1000);
    const rest = Math.floor(n % 1000);
    const parts = [];
    if (cr) parts.push(`${cr} Crore`);
    if (lakh) parts.push(`${lakh} Lakh`);
    if (thou) parts.push(`${thou} Thousand`);
    if (rest) parts.push(`${rest}`);
    return 'Rupees ' + parts.join(' ') + ' Only';
  };

  return (
    <div className="page">
      <div className="page-header">
        <div>
          <div className="muted tiny mb-1" style={{ cursor: 'pointer' }} onClick={() => navigate('invoices')}>
            <Icon name="chevronLeft" size={12}/> Invoices
          </div>
          <h1 className="page-title">
            Tax Invoice — <span className="mono">{docNo}</span>
            {inv ? <span className={`badge ${inv.consolidated ? 'success' : 'accent'} dot`} style={{ marginLeft: 8 }}>{docType}</span> : (so.status === 'Payment Pending' ? <span className="badge warning dot" style={{ marginLeft: 8 }}>Payment Due</span> : <span className="badge success dot" style={{ marginLeft: 8 }}>Paid</span>)}
          </h1>
          <div className="page-sub">SO Ref <span className="mono">{so.so_no}</span> · {c.name}</div>
        </div>
        <div className="page-actions">
          <button className="btn"><Icon name="mail" size={13}/>Email to customer</button>
          <button className="btn"><Icon name="print" size={13}/>Print / Download</button>
          <button className="btn btn-primary"><Icon name="flag" size={13}/>View e-Way Bill</button>
        </div>
      </div>

      <div className="detail-grid">
        <div className="doc-paper">
          <div className="doc-header">
            <div>
              <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
                <div className="brand-mark" style={{ width: 32, height: 32, fontSize: 16 }}>B</div>
                <div>
                  <h1>{state.org.name}</h1>
                  <div className="small muted">{state.org.address}</div>
                  <div className="small mono">GSTIN: {state.org.gstin} · State: {state.org.state}</div>
                </div>
              </div>
            </div>
            <div style={{ textAlign: 'right' }}>
              <div style={{ fontSize: 16, fontWeight: 600, letterSpacing: '0.06em' }}>TAX INVOICE</div>
              <div className="small mono">{docNo}</div>
              <div className="tiny muted">{inv && !inv.consolidated ? `${docType} · qty-proportional` : 'Original for recipient'}</div>
            </div>
          </div>

          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 10 }}>
            <div>
              <div className="tiny muted" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>Billed to</div>
              <div style={{ fontSize: 13, fontWeight: 600 }}>{c.name}</div>
              <div className="small">{c.address}</div>
              <div className="small mono">GSTIN: {c.gstin}</div>
              <div className="small">State: {c.state}</div>
            </div>
            <div>
              <div className="tiny muted" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>Shipped to</div>
              <div className="small">{so.ship_to}</div>
              <div className="tiny muted mt-2" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>Reference</div>
              <div className="small">Customer PO: <span className="mono">{so.customer_po}</span></div>
              <div className="small">SO Ref: <span className="mono">{so.so_no}</span></div>
            </div>
          </div>

          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8, padding: 8, background: 'var(--bg-subtle)', borderRadius: 4, marginBottom: 10 }}>
            <div><div className="tiny muted">Invoice date</div><div className="mono small">{fmtDate(docDate)}</div></div>
            <div><div className="tiny muted">Place of supply</div><div className="small">{c.state}</div></div>
            <div><div className="tiny muted">e-Way Bill</div><div className="mono small">EWB-2312-0231-{(45000 + parseInt(so.id.replace('so-','')) * 33).toString()}</div></div>
            <div><div className="tiny muted">IRN</div><div className="mono small">{(so.id + '...e8f9').replace('so-','')}aab2…</div></div>
          </div>

          <table>
            <thead>
              <tr><th>#</th><th>Description</th><th>HSN</th><th>Qty</th><th>Rate</th><th>Amount</th></tr>
            </thead>
            <tbody>
              {docLines ? docLines.map((l, i) => {
                const cat = l.category_id ? getCategory(l.category_id) : null;
                const prod = !cat && l.ref_id ? getProduct(l.ref_id) : null;
                const soLine = l.ref_id ? (so.lines || []).find(x => x.id === l.ref_id) : null;
                const clientName = soLine && soLine.client_name;
                // The customer's own wording wins. Stamped on the line when the
                // invoice was raised; for invoices issued before that existed,
                // resolved live from the order's customer_ref so an already-sent
                // document still reads in their language rather than ours.
                const cust = invoiceCustName(so, l, getProduct);
                const name = (cust && cust.label) || clientName || (cat ? cat.name : (l.label || (prod && prod.name) || 'Item'));
                const ourName = (cat ? cat.name : (prod && prod.name)) || l.label || '';
                const custCode = (l.cust_code || (cust && cust.code) || '').trim();
                const hsn = cat ? cat.hsn : (prod && prod.hsn) || '—';
                return (
                  <tr key={i}>
                    <td>{i + 1}</td>
                    <td>
                      <strong>{name}</strong>
                      {custCode && <div className="tiny muted mono">{custCode}</div>}
                      {/* Our own name underneath, for the warehouse and for us —
                          only when it actually differs from what we billed. */}
                      {ourName && ourName !== name && <div className="tiny muted">{ourName}</div>}
                      {!custCode && !ourName && cat && <div className="tiny muted">{cat.bundle_desc}</div>}
                    </td>
                    <td className="mono">{hsn}</td>
                    <td className="num mono">{l.qty}</td>
                    <td className="num mono">{inr(l.unit_price)}</td>
                    <td className="num mono">{inr(l.amount)}</td>
                  </tr>
                );
              }) : so.lines.map((l, i) => {
                const cat = getCategory(l.category_id);
                return (
                  <tr key={l.id}>
                    <td>{i+1}</td>
                    <td>
                      <strong>{l.client_name || cat.name}</strong>
                      <div className="tiny muted">{cat.bundle_desc}</div>
                      {/* Internal BOM NOT shown to customer — that's the point */}
                    </td>
                    <td className="mono">{cat.hsn}</td>
                    <td className="num mono">{l.bundle_qty}</td>
                    <td className="num mono">{inr(l.unit_price)}</td>
                    <td className="num mono">{inr(l.bundle_qty * l.unit_price)}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>

          <table className="totals">
            <tbody>
              {billAdj > 0 && <tr><td>Ordered value</td><td className="num mono right">{inr(orderedSubtotal)}</td></tr>}
              {billAdj > 0 && <tr><td style={{ color: 'var(--danger)' }}>Less: items not supplied</td><td className="num mono right" style={{ color: 'var(--danger)' }}>−{inr(billAdj)}</td></tr>}
              <tr><td>{billAdj > 0 ? 'Net subtotal' : 'Subtotal'}</td><td className="num mono right">{inr(subtotal)}</td></tr>
              {sameState ? (
                <>
                  <tr><td>CGST @ 9%</td><td className="num mono right">{inr(cgst)}</td></tr>
                  <tr><td>SGST @ 9%</td><td className="num mono right">{inr(sgst)}</td></tr>
                </>
              ) : (
                <tr><td>IGST @ 18%</td><td className="num mono right">{inr(igst)}</td></tr>
              )}
              <tr><td>Round-off</td><td className="num mono right">₹0</td></tr>
              <tr className="grand"><td>Grand total</td><td className="num mono right">{inr(grand)}</td></tr>
            </tbody>
          </table>

          <div style={{ marginTop: 14, padding: 8, borderTop: '1px solid var(--border-strong)' }}>
            <div className="tiny" style={{ fontWeight: 600 }}>Amount in words:</div>
            <div className="small">{toWords(grand)}</div>
          </div>

          <div style={{ marginTop: 16, display: 'grid', gridTemplateColumns: '1fr auto', gap: 16, alignItems: 'flex-end' }}>
            <div>
              <div className="tiny muted" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>Bank details</div>
              <div className="small mono">HDFC Bank · Powai Branch</div>
              <div className="small mono">A/c: 0042 9981 12340 · IFSC: HDFC0000042</div>
              <div className="tiny muted mt-2">Terms: {so.payment_terms} from invoice date</div>
            </div>
            <div className="qr-ph" title="IRN QR placeholder"/>
          </div>

          <div className="tiny muted" style={{ marginTop: 12, textAlign: 'center', borderTop: '1px solid var(--border)', paddingTop: 8 }}>
            This is a system-generated invoice. For queries, write to billing@brightline.in
          </div>
        </div>

        <div className="stack">
          <div className="card">
            <div className="card-header"><h3 className="card-title">e-Way Bill</h3></div>
            <div className="card-body">
              <div className="dl">
                <dt>EWB No</dt><dd className="mono">EWB-2312-0231-{(45000 + parseInt(so.id.replace('so-','')) * 33).toString()}</dd>
                <dt>Generated via</dt><dd>NIC API · auto</dd>
                <dt>Valid until</dt><dd className="mono">{addDays(docDate, 3) ? `${fmtDate(addDays(docDate, 3))} 23:59` : '—'}</dd>
                <dt>Vehicle</dt><dd className="mono">MH-04-EZ-9921</dd>
                <dt>Distance</dt><dd>247 km</dd>
                <dt>Type</dt><dd>Outward · Supply</dd>
              </div>
              <button className="btn mt-2" style={{ width: '100%' }}>View on NIC portal ↗</button>
            </div>
          </div>
          <div className="card">
            <div className="card-header"><h3 className="card-title">e-Invoice (IRN)</h3></div>
            <div className="card-body">
              <div className="dl">
                <dt>IRN</dt><dd className="mono small">{('a4f9e8a7b3' + so.id).slice(0,20)}…</dd>
                <dt>Ack No</dt><dd className="mono">192600{parseInt(so.id.replace('so-',''))*7}</dd>
                <dt>Ack Date</dt><dd className="mono">{fmtDate(so.invoice_date)}</dd>
                <dt>Status</dt><dd><span className="badge success dot">Accepted</span></dd>
              </div>
            </div>
          </div>
          <div className="card">
            <div className="card-header"><h3 className="card-title">Internal BOM (hidden from customer)</h3></div>
            <div className="card-body small">
              <div className="muted mb-1">Components delivered against this invoice — not on PDF:</div>
              {so.lines.flatMap(l => l.components.map(c => ({ c, bundle: l.bundle_qty || 1 }))).map(({ c, bundle }, i) => {
                const p = getProduct(c.product_id);
                return <div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', borderBottom: '1px solid var(--border)' }}>
                  <span className="small">{p.name}</span>
                  <span className="mono small">{c.qty * bundle}{bundle > 1 && <span className="muted"> ({c.qty}×{bundle})</span>}</span>
                </div>;
              })}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

// ===== Collections =====
function CollectionsDashboard() {
  const { state, navigate, getCustomer } = useStore();
  const toast = useToast();
  const pendingInvoices = state.sales_orders.filter(s => s.status === 'Payment Pending' && s.invoice_no);
  const totalOutstanding = pendingInvoices.reduce((s, i) => s + i.invoice_amount, 0);

  const buckets = [
    { label: '0–30 days', max: 30, color: 'oklch(0.55 0.10 155)' },
    { label: '31–60 days', max: 60, color: 'oklch(0.62 0.13 70)' },
    { label: '61–90 days', max: 90, color: 'oklch(0.55 0.15 40)' },
    { label: '90+ days', max: 9999, color: 'oklch(0.52 0.16 25)' },
  ];
  const bucketed = buckets.map((b, i) => {
    const prev = i === 0 ? -9999 : buckets[i-1].max;
    const items = pendingInvoices.filter(inv => {
      const d = inv.days_overdue || 0;
      return d > prev && d <= b.max;
    });
    return { ...b, items, total: items.reduce((s, i) => s + i.invoice_amount, 0) };
  });

  return (
    <div className="page">
      <div className="page-header">
        <div>
          <h1 className="page-title">Collections</h1>
          <div className="page-sub">Total outstanding {inr(totalOutstanding)} across {pendingInvoices.length} invoices</div>
        </div>
        <div className="page-actions">
          <button className="btn"><Icon name="download" size={13}/>Export ageing</button>
          <button className="btn"><Icon name="msg" size={13}/>Bulk WhatsApp</button>
        </div>
      </div>

      <div className="kpi-grid mb-3">
        {bucketed.map((b, i) => (
          <div key={i} className="kpi" style={{ borderTop: `3px solid ${b.color}` }}>
            <div className="kpi-label">{b.label}</div>
            <div className="kpi-value">{inrK(b.total)}</div>
            <div className="kpi-delta">{b.items.length} invoice{b.items.length !== 1 ? 's' : ''}</div>
          </div>
        ))}
      </div>

      <div className="card mb-3">
        <div className="card-header">
          <h3 className="card-title">Priority Follow-up</h3>
          <span className="card-sub">Sorted by days overdue · highest value first</span>
        </div>
        <div className="card-body flush">
          <table className="t zebra">
            <thead><tr>
              <th>Invoice</th><th>Customer</th><th className="num">Amount</th><th className="num">Days Overdue</th>
              <th>Last contact</th><th>By</th><th>Actions</th>
            </tr></thead>
            <tbody>
              {[...pendingInvoices].sort((a,b) => (b.days_overdue || 0) - (a.days_overdue || 0)).map(inv => {
                const c = getCustomer(inv.customer_id);
                const overdue = inv.days_overdue || 0;
                return (
                  <tr key={inv.id}>
                    <td>
                      <div className="mono">{inv.invoice_no}</div>
                      <div className="tiny muted mono">{inv.so_no}</div>
                    </td>
                    <td>
                      <div>{c.name}</div>
                      <div className="tiny muted">{c.tier} · {c.terms}</div>
                    </td>
                    <td className="num"><strong>{inr(inv.invoice_amount)}</strong></td>
                    <td className="num">
                      {overdue > 0 ? (
                        <span style={{ color: overdue > 60 ? 'var(--danger)' : overdue > 30 ? 'var(--warning)' : 'var(--text)', fontWeight: 600 }}>
                          {overdue}d
                        </span>
                      ) : <span className="muted">due today</span>}
                    </td>
                    <td className="mono small">{overdue > 0 ? fmtDate(new Date(Date.now() - (overdue - 7) * 86400000 * (overdue > 14 ? 1 : 0)).toISOString().slice(0,10)) : '—'}</td>
                    <td>{overdue > 0 ? <span className="small">Tara P</span> : <span className="muted">—</span>}</td>
                    <td>
                      <div style={{ display: 'flex', gap: 4 }}>
                        <button className="btn btn-sm" title="Call" onClick={() => toast(`Calling ${c.contact}…`)}><Icon name="phone" size={11}/></button>
                        <button className="btn btn-sm" title="Email" onClick={() => toast(`Reminder emailed to ${c.contact}`)}><Icon name="mail" size={11}/></button>
                        <button className="btn btn-sm" title="WhatsApp" onClick={() => toast(`WhatsApp sent to ${c.phone}`)}><Icon name="msg" size={11}/></button>
                        <button className="btn btn-sm" onClick={() => navigate(`customers/${c.id}/ledger`)}>Ledger</button>
                      </div>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </div>

      <div className="split-2">
        <div className="card">
          <div className="card-header"><h3 className="card-title">Today's follow-up log</h3></div>
          <div className="card-body flush">
            <div className="queue-item">
              <Icon name="phone" size={14} color="var(--accent)"/>
              <div className="grow">
                <div className="small">Called <strong>Hardik Mehta</strong> — Mehta Textiles · INV/FY26/0049</div>
                <div className="tiny muted">"Will release payment by Friday EOD" · logged 09:45</div>
              </div>
              <button className="btn btn-ghost btn-sm">Log next</button>
            </div>
            <div className="queue-item">
              <Icon name="msg" size={14} color="var(--success)"/>
              <div className="grow">
                <div className="small">WhatsApp sent to <strong>Karthik R</strong> — Southern Polymers</div>
                <div className="tiny muted">Auto-reminder template T+0 · seen 11:23</div>
              </div>
              <button className="btn btn-ghost btn-sm">Reply</button>
            </div>
            <div className="queue-item">
              <Icon name="mail" size={14} color="var(--info)"/>
              <div className="grow">
                <div className="small">Email follow-up scheduled — <strong>Reema Saxena</strong> · Orbit Hospitals</div>
                <div className="tiny muted">14:00 today · auto-template "T-7"</div>
              </div>
            </div>
          </div>
        </div>
        <div className="card">
          <div className="card-header"><h3 className="card-title">Escalation Rules</h3></div>
          <div className="card-body small">
            <div className="dl" style={{ gridTemplateColumns: '1fr auto', rowGap: 8 }}>
              <dt>+7 days before due</dt><dd>Gentle email + SMS</dd>
              <dt>On due date</dt><dd>Email + WhatsApp</dd>
              <dt>+7 days overdue</dt><dd>Escalation email</dd>
              <dt>+30 days overdue</dt><dd>Sales lead notified</dd>
              <dt>+60 days overdue</dt><dd>MD notified · hold new orders</dd>
              <dt>+90 days overdue</dt><dd>Flag for legal action</dd>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

// ===== Partial / Final invoicing engine =====
// Pure helpers (usable inside a mutate updater with the latest state).
function _soSub(so) { return (so.lines || []).reduce((a, l) => a + (l.bundle_qty || 0) * (l.unit_price || 0), 0); }
function _sellOf(state, pid) { const p = ((state && state.products) || []).find(x => x.id === pid); return p ? (p.sell || 0) : 0; }
// Effective per-bundle price after removing non-billable components (by their sell
// value); 0 if the whole bundle is non-billable. This is the rate the client is charged.
function _effUnit(so, state, l) {
  if (l.non_billable) return 0;
  const cut = (l.components || []).filter(c => c.non_billable).reduce((a, c) => a + (c.qty || 0) * _sellOf(state, c.product_id), 0);
  return Math.max(0, (l.unit_price || 0) - cut);
}
function _soNonBillable(so, state) { return (so.lines || []).reduce((a, l) => a + (l.bundle_qty || 0) * ((l.unit_price || 0) - _effUnit(so, state, l)), 0); }
function _soBilled(so, state) { return Math.max(0, _soSub(so) - _soNonBillable(so, state) - (so.bill_adjustments || []).reduce((a, x) => a + (Number(x.amount) || 0), 0)); }
// Shared with the store's soBilledSubtotal (line + component non-billable value).
window.soNonBillableValue = function (so, products) { return _soNonBillable(so, { products: products || [] }); };

// Accepted material received against this SO (GRNs of its POs) + pool stock in hand,
// minus any units later diverted to the Master Pool (so the client is never billed
// for items sent away). The matching bill_adjustment reconciles the order value.
function soReceivedQty(so, state) {
  const acc = {};
  const posById = {};
  (state.vendor_pos || []).forEach(p => { if (poServesSO(p, so.id)) posById[p.id] = p; });
  (state.grns || []).forEach(g => { const po = posById[g.po_id]; if (po) (g.items || []).forEach(it => { acc[it.product_id] = (acc[it.product_id] || 0) + grnLineSoQty(it, po, so.id); }); });
  (so.pool_alloc || []).forEach(a => { acc[a.product_id] = (acc[a.product_id] || 0) + (Number(a.qty) || 0); });
  const out = (window.soPoolOut ? window.soPoolOut(so) : {});
  Object.keys(out).forEach(pid => { acc[pid] = Math.max(0, (acc[pid] || 0) - out[pid]); });
  return acc;
}

// Component-level invoiced ledger (works for both bundle- and component-mode
// invoices, so the two reconcile exactly against received material).
function soInvoicedComp(so) {
  const inv = {};
  (so.invoices || []).forEach(i => Object.entries(i.comp_consumed || {}).forEach(([pid, q]) => { inv[pid] = (inv[pid] || 0) + (Number(q) || 0); }));
  return inv;
}

// Per-component: received vs invoiced vs invoiceable (for component-level billing).
function soComponentState(so, state, getProduct) {
  const recv = soReceivedQty(so, state);
  const invd = soInvoicedComp(so);
  return Object.keys(recv).filter(pid => (recv[pid] || 0) > 0).map(pid => {
    const p = getProduct ? getProduct(pid) : null;
    const received = recv[pid] || 0, invoiced = invd[pid] || 0;
    return { product_id: pid, product: p, received, invoiced, invoiceable: Math.max(0, received - invoiced), sell: p ? (p.sell || 0) : 0 };
  });
}

// Per-line fulfilment: complete bundles the *not-yet-invoiced* received components
// support (greedy allocation) → invoiceable bundles now.
function soInvoiceState(so, state, getProduct) {
  const recv = soReceivedQty(so, state);
  const invd = soInvoicedComp(so);
  const avail = {};
  Object.keys(recv).forEach(pid => { avail[pid] = (recv[pid] || 0) - (invd[pid] || 0); });
  return (so.lines || []).map(l => {
    let completable = l.bundle_qty || 0;
    (l.components || []).forEach(c => { const per = c.qty || 0; if (per > 0) completable = Math.min(completable, Math.floor((avail[c.product_id] || 0) / per)); });
    completable = Math.max(0, Math.min(completable, l.bundle_qty || 0));
    const eff = _effUnit(so, state, l);
    const invoiceableNow = eff > 0 ? completable : 0;
    // A real-priced, real-value bundle can still show 0 invoiceable because ONE
    // of several components hasn't arrived yet — correct (never bill a bundle
    // that isn't actually complete), but "0" alone reads as a dead end exactly
    // like the no-price case did. Computed before the avail subtraction below:
    // when invoiceableNow is 0 that subtraction is a no-op anyway (completable
    // * qty = 0), so this reflects true availability either way.
    const blockedBy = invoiceableNow === 0 && eff > 0
      ? (l.components || []).filter(c => (c.qty || 0) > 0 && (avail[c.product_id] || 0) < c.qty)
          .map(c => ({ product_id: c.product_id, name: (getProduct ? getProduct(c.product_id) : null)?.name || c.product_id,
                       have: avail[c.product_id] || 0, need: c.qty }))
      : [];
    (l.components || []).forEach(c => { avail[c.product_id] = (avail[c.product_id] || 0) - completable * (c.qty || 0); });
    return { line_id: l.id, category_id: l.category_id, ordered: l.bundle_qty || 0, unit_price: eff, components: l.components || [], invoiceableNow, non_billable: eff <= 0, blockedBy };
  });
}

// Fraction of each bundle currently fulfilled, measured BY ITEM COUNT
// (distinct components fully received ÷ total distinct components), allocated
// greedily across bundles when a product is shared.
function soBundleFraction(so, state) {
  const avail = { ...soReceivedQty(so, state) };   // already nets out pool-out
  const out = {};
  (so.lines || []).forEach(l => {
    const comps = l.components || [];
    if (!comps.length) { out[l.id] = 1; return; }
    let count = 0;
    comps.forEach(c => {
      const req = (c.qty || 0) * (l.bundle_qty || 1);
      if (req <= 0) { count++; return; }
      if ((avail[c.product_id] || 0) >= req) { count++; avail[c.product_id] = (avail[c.product_id] || 0) - req; }
    });
    out[l.id] = count / comps.length;
  });
  return out;
}
// Cumulative fraction of each bundle already billed (non-consolidated invoices).
function soLineInvoicedFraction(so) {
  const qty = {};
  (so.invoices || []).filter(i => !i.consolidated).forEach(inv => (inv.lines || []).forEach(ln => { if (ln.ref_id) qty[ln.ref_id] = (qty[ln.ref_id] || 0) + (Number(ln.qty) || 0); }));
  const out = {};
  (so.lines || []).forEach(l => { const bq = l.bundle_qty || 1; out[l.id] = bq > 0 ? (qty[l.id] || 0) / bq : 0; });
  return out;
}
const _nonConsolidatedTotal = (invs) => (invs || []).filter(i => !i.consolidated).reduce((a, i) => a + (i.total || 0), 0);

// ===== Client invoice GST — the real per-line rate, not one flat 18% =====
// The vendor PO's own e-Bill already taxes per line, at each line's real rate
// (see docs/po-tax.md); the client invoice never did, despite every category
// and product in the catalogue carrying its own real `gst` field (5/12/18/28,
// the actual Indian slabs), populated from a customer's own imported sheet
// when their "tax rate %" column says so (screens-import.jsx). A flat 18% on
// a mixed-rate order was wrong by however much the other lines' real rates
// differed from 18 — invisible as long as every seed product happened to
// carry 18%, live the moment one doesn't.
//
// Stamped onto each line at build time (`gst_pct`), never re-read from the
// catalogue later — an issued invoice's tax must not change because a
// product's GST rate was edited afterward. No TDS/TCS here: those are a
// vendor-PAYMENT withholding concept (see docs/po-tax.md), not a customer-
// invoice one, so the client-invoice model only needs GST itself, split into
// CGST+SGST within the seller's own state or IGST across states.
function invoiceLineGstPct(state, refId, categoryId) {
  if (categoryId) { const cat = (state.categories || []).find(c => c.id === categoryId); if (cat && cat.gst != null) return Number(cat.gst) || 0; }
  if (refId) { const p = (state.products || []).find(x => x.id === refId); if (p && p.gst != null) return Number(p.gst) || 0; }
  return 18;   // no category/product to read (e.g. a service line) — the one rate every seed catalogue entry and the importer's own default both carry
}
window.invoiceLineGstPct = invoiceLineGstPct;

// Whether this order's customer sits in the selling org's own state — decides
// CGST+SGST (within-state) vs IGST (cross-state), same rule InvoiceDetail
// already used, now shared rather than re-derived at view time too.
function invoiceSameState(so, state) {
  const c = (state.customers || []).find(x => x.id === so.customer_id);
  return !!(c && state.org && c.state === state.org.state);
}
window.invoiceSameState = invoiceSameState;

// Sums real tax from an invoice's OWN lines, each already stamped with its
// own gst_pct — never one rate blended across the whole subtotal. Tax is
// rounded once, on the summed total, same single-rounding discipline
// docs/po-tax.md already established for the PO side ("every tax is worked
// out on the taxable value... never on a running total").
function invoiceTaxFromLines(lines, sameState) {
  let gst = 0;
  (lines || []).forEach(l => { gst += (l.amount || 0) * (Number(l.gst_pct != null ? l.gst_pct : 18) || 0) / 100; });
  gst = Math.round(gst);
  const half = Math.round(gst / 2);
  return sameState ? { cgst: half, sgst: gst - half, igst: 0, gst } : { cgst: 0, sgst: 0, igst: gst, gst };
}
window.invoiceTaxFromLines = invoiceTaxFromLines;

// For the one invoice shape that bills a lump sum with no per-line detail
// ('final' mode's single "Balance of order" line, and a legacy pre-invoice-
// object SO with no stored breakdown at all) — the order's own lines' real
// rates, weighted by each line's own value, so a mixed-rate order still
// lands close to its true blended rate instead of a flat 18% that ignores
// every rate on the order.
function soWeightedGstPct(so, state) {
  let weighted = 0, weightSum = 0;
  (so.lines || []).forEach(l => {
    const val = (l.bundle_qty || 0) * (l.unit_price || 0);
    if (val <= 0) return;
    weighted += val * invoiceLineGstPct(state, null, l.category_id);
    weightSum += val;
  });
  return weightSum > 0 ? weighted / weightSum : 18;
}
window.soWeightedGstPct = soWeightedGstPct;

// ===== Implementation billing (hourly rate × hours summed from the supervisor's
// daily logs). Billed as a single 'Implementation services' line on the client
// invoice — the BOQ items themselves never appear on it. =====
function soImplHours(so) { const im = so.extra && so.extra.implementation; return im ? (im.daily_logs || []).reduce((a, l) => a + (Number(l.hours) || 0), 0) : 0; }
function soImplCharge(so) { const im = so.extra && so.extra.implementation; return im ? Math.round((Number(im.hourly_rate) || 0) * soImplHours(so)) : 0; }
function soImplInvoiced(so) { return (so.invoices || []).filter(i => !i.consolidated).reduce((a, i) => a + (i.lines || []).filter(l => l.kind === 'impl').reduce((b, l) => b + (l.amount || 0), 0), 0); }
function soImplInvoicedHours(so) { return (so.invoices || []).filter(i => !i.consolidated).reduce((a, i) => a + (i.lines || []).filter(l => l.kind === 'impl').reduce((b, l) => b + (Number(l.qty) || 0), 0), 0); }
// Supply-only invoiced subtotal (strip implementation lines) — for the supply cap.
function soSupplyInvoicedSub(so) { return (so.invoices || []).filter(i => !i.consolidated).reduce((a, i) => a + (i.subtotal || 0), 0) - soImplInvoiced(so); }
// The newly-billable implementation line (delta hours since last invoice), if any.
function _implDeltaLine(so) {
  const im = so.extra && so.extra.implementation;
  if (!im) return null;
  const deltaHrs = Math.max(0, soImplHours(so) - soImplInvoicedHours(so));
  if (deltaHrs <= 0) return null;
  const rate = Number(im.hourly_rate) || 0;
  const amount = Math.round(deltaHrs * rate);
  if (amount <= 0) return null;
  return { kind: 'impl', ref_id: 'impl', label: `Implementation services — ${deltaHrs} hr(s) @ ${inr(rate)}/hr`, qty: deltaHrs, unit_price: rate, amount, gst_pct: 18 };
}

// Item-count fractional invoice: bills the newly-fulfilled fraction of each
// bundle (e.g. 0.8 then 0.2). Always type 'Partial'; the consolidated final is
// raised separately once everything is in.
function buildFractionInvoice(so, state, currentUser, getUser) {
  if ((so.invoices || []).length === 0 && so.invoice_no) return null;   // legacy full invoice
  const frac = soBundleFraction(so, state);
  const invF = soLineInvoicedFraction(so);
  const lines = []; const comp = {}; let subtotal = 0;
  (so.lines || []).forEach(l => {
    const eff = _effUnit(so, state, l);   // net rate after non-billable line/components
    if (eff <= 0) return;                 // whole line (or all its value) is non-billable
    const newF = Math.max(0, (frac[l.id] || 0) - (invF[l.id] || 0));
    if (newF <= 0.0005) return;
    const qty = Math.round(newF * (l.bundle_qty || 1) * 1000) / 1000;
    if (qty <= 0) return;
    const amount = Math.round(qty * eff);
    if (amount <= 0) return;
    const cn = custRefName(l.customer_ref) || (String(l.client_name || '').trim() ? { label: l.client_name.trim(), code: '' } : null);
    lines.push({ kind: 'bundle', ref_id: l.id, category_id: l.category_id, qty, unit_price: eff, amount,
                 cust_label: cn ? cn.label : '', cust_code: cn ? cn.code : '', gst_pct: invoiceLineGstPct(state, null, l.category_id) });
    subtotal += amount;
    (l.components || []).forEach(c => { comp[c.product_id] = (comp[c.product_id] || 0) + (c.qty || 0) * qty; });
  });
  // Cap SUPPLY billing to the order's remaining supply value (guards against
  // mixed manual/auto invoicing and rounding drift). Implementation is separate.
  const remainingBilled = Math.max(0, _soBilled(so, state) - soSupplyInvoicedSub(so));
  subtotal = Math.min(subtotal, Math.round(remainingBilled));
  // Implementation rides on the SAME invoice — the hours logged since the last one.
  const implLine = _implDeltaLine(so);
  if (implLine) { lines.push(implLine); subtotal += implLine.amount; }
  if (subtotal <= 0.5) return null;
  const tax = invoiceTaxFromLines(lines, invoiceSameState(so, state));
  const total = subtotal + tax.gst;
  const role = (getUser && currentUser) ? (getUser(currentUser)?.role || '') : '';
  const invoice = { id: 'inv-' + Date.now() + Math.random().toString(36).slice(2, 5), no: _invNoFor(so, state), date: TODAY, type: 'Partial', mode: 'itemfraction', lines, comp_consumed: comp, subtotal, cgst: tax.cgst, sgst: tax.sgst, igst: tax.igst, gst: tax.gst, total, created_by: currentUser || null, role };
  const invoices = [...(so.invoices || []), invoice];
  const nextSO = { ...so, invoices, invoice_no: invoice.no, invoice_date: TODAY, invoice_amount: _nonConsolidatedTotal(invoices) };
  return { so: nextSO, invoice, fully: false };
}
// Consolidated final tax invoice: the whole order at full qty (e.g. qty 1), as a
// single shareable document. Raised automatically once every bundle is fully
// received AND fully billed by the partials. NOT added to the SO total again.
function buildConsolidatedInvoice(so, state, currentUser, getUser) {
  if ((so.invoices || []).some(i => i.consolidated)) return null;
  if ((so.invoices || []).length === 0 && so.invoice_no) return null;
  const lines = so.lines || [];
  if (!lines.length) return null;   // impl-only SOs don't get a consolidated (the impl invoice is the bill)
  // Gate on AMOUNTS + actual receipt, not per-line fractions. The old per-line
  // fraction test could stall forever: buildFractionInvoice skips lines whose
  // rounded amount is 0 (so the invoiced fraction never reached 1.0), and
  // soBundleFraction subtracts pool-out qty (so the received fraction never
  // reached 1.0 once anything was sent to the Master Pool).
  if (!(so.invoices || []).some(i => !i.consolidated)) return null;          // no partial raised yet
  if ((_soBilled(so, state) - soSupplyInvoicedSub(so)) > 1) return null;            // supply not fully billed
  if (window.soFullyReceived && !window.soFullyReceived(state, so)) return null;   // items still to receive
  // Only finalise once implementation is marked done AND every logged hour is billed.
  const im = so.extra && so.extra.implementation;
  if (im) {
    // Every logged implementation hour must already be billed (the partial raised
    // just before this one does that), otherwise the main invoice would understate.
    if ((soImplHours(so) - soImplInvoicedHours(so)) > 0.0001) return null;
    // Don't finalise before ANY hours exist unless the supervisor marked it done —
    // that would silently omit the implementation from the main invoice.
    if (im.status !== 'Done' && soImplHours(so) <= 0) return null;
  }
  const invLines = lines.map(l => ({ l, eff: _effUnit(so, state, l) })).filter(x => x.eff > 0).map(({ l, eff }) => ({ kind: 'bundle', ref_id: l.id, category_id: l.category_id, qty: l.bundle_qty || 1, unit_price: eff, amount: Math.round((l.bundle_qty || 1) * eff), gst_pct: invoiceLineGstPct(state, null, l.category_id) }));
  // The final main invoice shows supply + the whole implementation (total hours) in one doc.
  if (im && soImplCharge(so) > 0) invLines.push({ kind: 'impl', ref_id: 'impl', label: `Implementation services — ${soImplHours(so)} hr(s) @ ${inr(Number(im.hourly_rate) || 0)}/hr`, qty: soImplHours(so), unit_price: Number(im.hourly_rate) || 0, amount: soImplCharge(so), gst_pct: 18 });
  const subtotal = invLines.reduce((a, x) => a + x.amount, 0);
  if (subtotal <= 0.5) return null;
  const tax = invoiceTaxFromLines(invLines, invoiceSameState(so, state));
  const total = subtotal + tax.gst;
  const role = (getUser && currentUser) ? (getUser(currentUser)?.role || '') : '';
  const invoice = { id: 'inv-' + Date.now() + Math.random().toString(36).slice(2, 5), no: _invNoFor(so, state), date: TODAY, type: 'Final', mode: 'consolidated', consolidated: true, lines: invLines, comp_consumed: {}, subtotal, cgst: tax.cgst, sgst: tax.sgst, igst: tax.igst, gst: tax.gst, total, created_by: currentUser || null, role };
  const invoices = [...(so.invoices || []), invoice];
  const nextSO = { ...so, invoices, status: 'Invoiced', invoice_no: invoice.no, invoice_date: TODAY, invoice_amount: _nonConsolidatedTotal(invoices) };
  return { so: nextSO, invoice, fully: true };
}

// Build (don't commit) the next invoice. opts.mode: 'bundle' | 'component' | 'final'.
// opts.selections optionally caps qty per line (bundle) or per product (component);
// default = everything invoiceable. Records comp_consumed for the ledger.
function buildInvoice(so, state, opts, currentUser, getUser, getProduct) {
  const mode = (opts && opts.mode) || 'bundle';
  if (mode === 'itemfraction') return buildFractionInvoice(so, state, currentUser, getUser);
  if (mode === 'consolidated') return buildConsolidatedInvoice(so, state, currentUser, getUser);
  if ((so.invoices || []).length === 0 && so.invoice_no) return null;   // legacy direct invoice → done
  const sel = opts && opts.selections;
  const invoicedSub = (so.invoices || []).filter(i => !i.consolidated).reduce((a, i) => a + (i.subtotal || 0), 0);
  const remainingBilled = Math.max(0, _soBilled(so, state) - invoicedSub);
  if (remainingBilled <= 0.5) return null;

  const lines = []; const comp = {}; let subtotal = 0;
  if (mode === 'component') {
    soComponentState(so, state, getProduct).forEach(r => {
      const want = sel ? (Number(sel[r.product_id]) || 0) : r.invoiceable;
      const qty = Math.max(0, Math.min(want, r.invoiceable));
      if (qty > 0) {
        const cn = invoiceCustName(so, { ref_id: r.product_id }, getProduct);
        lines.push({ kind: 'component', ref_id: r.product_id, label: r.product ? r.product.name : r.product_id,
                     cust_label: cn ? cn.label : '', cust_code: cn ? cn.code : '',
                     qty, unit_price: r.sell, amount: qty * r.sell, gst_pct: invoiceLineGstPct(state, r.product_id, null) });
        comp[r.product_id] = (comp[r.product_id] || 0) + qty; subtotal += qty * r.sell;
      }
    });
  } else if (mode === 'final') {
    soComponentState(so, state, getProduct).forEach(r => { if (r.invoiceable > 0) comp[r.product_id] = (comp[r.product_id] || 0) + r.invoiceable; });
    subtotal = remainingBilled;
    // No single category/product here — this mode bills the whole remaining
    // balance as one lump sum — so the rate is the order's own lines weighted
    // by value, not a guess and not a flat 18% that ignores every real rate.
    lines.push({ kind: 'balance', ref_id: 'balance', label: 'Balance of order', qty: 1, unit_price: Math.round(subtotal), amount: Math.round(subtotal), gst_pct: soWeightedGstPct(so, state) });
  } else { // bundle
    soInvoiceState(so, state, getProduct).forEach(x => {
      const want = sel ? (Number(sel[x.line_id]) || 0) : x.invoiceableNow;
      const qty = Math.max(0, Math.min(want, x.invoiceableNow));
      if (qty > 0) {
        const bl = (so.lines || []).find(y => y.id === x.line_id);
        const cn = bl ? (custRefName(bl.customer_ref) || (String(bl.client_name || '').trim() ? { label: bl.client_name.trim(), code: '' } : null)) : null;
        lines.push({ kind: 'bundle', ref_id: x.line_id, category_id: x.category_id, qty, unit_price: x.unit_price, amount: qty * x.unit_price,
                     cust_label: cn ? cn.label : '', cust_code: cn ? cn.code : '', gst_pct: invoiceLineGstPct(state, null, x.category_id) });
        (x.components || []).forEach(c => { comp[c.product_id] = (comp[c.product_id] || 0) + (c.qty || 0) * qty; });
        subtotal += qty * x.unit_price;
      }
    });
  }
  if (subtotal <= 0.5) return null;
  subtotal = Math.min(Math.round(subtotal), Math.round(remainingBilled));
  const tax = invoiceTaxFromLines(lines, invoiceSameState(so, state));
  const total = subtotal + tax.gst;
  const fully = (remainingBilled - subtotal) <= 0.5;
  const role = (getUser && currentUser) ? (getUser(currentUser)?.role || '') : '';
  const invoice = {
    id: 'inv-' + Date.now() + Math.random().toString(36).slice(2, 5),
    no: _invNoFor(so, state), date: TODAY,
    type: fully ? 'Final' : 'Partial', mode, lines, comp_consumed: comp,
    subtotal, cgst: tax.cgst, sgst: tax.sgst, igst: tax.igst, gst: tax.gst, total, created_by: currentUser || null, role,
  };
  const invoices = [...(so.invoices || []), invoice];
  // See soReviewStillOpen() in utils.jsx — don't force the stored status to
  // 'Invoiced' while the client review is still open.
  const nextSO = { ...so, invoices, invoice_no: invoice.no, invoice_date: TODAY, invoice_amount: _nonConsolidatedTotal(invoices),
                   status: (fully && !soReviewStillOpen(state, so)) ? 'Invoiced' : so.status };
  return { so: nextSO, invoice, fully };
}

// ===========================================================================
// Invoice a delivery challan
// ===========================================================================
// Billing on receipt charges the customer for goods still in our own godown.
// A trading company bills what it actually shipped — and a dispatch is the only
// point where the quantity is final, because it can be partial.
//
// Two things this must get right:
//
//   THE NAME. The customer ordered against their own part numbers and will
//   match the invoice against their own PO. Billing them in OUR wording makes
//   the document unreconcilable at their end, so the line label is the
//   customer's name for the item — captured onto the challan at dispatch, not
//   looked up now, so a later mapping edit cannot rewrite an issued invoice.
//
//   THE MONEY. Priced from the ORDER's own per-item prices, capped at what is
//   still uninvoiced, and refusing to issue the same challan twice.
// ===========================================================================
// What the CUSTOMER calls this line
// ===========================================================================
// A customer reconciles our invoice against their own purchase order. Billing
// them in our wording makes that impossible, so every invoice line has to carry
// their name for the item.
//
// The importer already keeps it: each order line and each component holds a
// customer_ref taken verbatim from the sheet they sent — their Sr. No., their
// part number, their description. That is the most faithful source there is, and
// it needs no lookup.
//
// Order of preference: their description, their part number, the client name
// Sales typed on the line, then ours. Returns null when we genuinely never had
// their wording, so callers fall back rather than printing a blank.
function custRefName(ref) {
  if (!ref || typeof ref !== 'object') return null;
  const desc = String(ref.desc || '').trim();
  const code = String(ref.code || '').trim();
  if (desc) return { label: desc, code: code };
  if (code) return { label: code, code: code };
  const equip = String(ref.equip || '').trim();
  return equip ? { label: equip, code: '' } : null;
}

// For an invoice line, whichever kind it is.
function invoiceCustName(so, line, getProduct) {
  if (!so || !line) return null;
  if (line.cust_label) return { label: line.cust_label, code: line.cust_code || '' };

  // A bundle line points at an order line.
  if (line.ref_id) {
    const soLine = (so.lines || []).find(x => x.id === line.ref_id);
    if (soLine) {
      const hit = custRefName(soLine.customer_ref);
      if (hit) return hit;
      const cn = String(soLine.client_name || '').trim();
      if (cn) return { label: cn, code: '' };
    }
  }
  // A component line points at a product — find it inside the order's lines.
  if (line.ref_id) {
    for (const l of (so.lines || [])) {
      const c = (l.components || []).find(x => x.product_id === line.ref_id);
      if (!c) continue;
      const hit = custRefName(c.customer_ref);
      if (hit) return hit;
    }
  }
  return null;
}

// Stamp the customer wording onto lines as they are built, so an invoice that
// has been ISSUED never changes because a mapping was edited afterwards.
function stampCustNames(so, lines, getProduct) {
  return (lines || []).map(l => {
    if (l.cust_label) return l;
    const hit = invoiceCustName(so, l, getProduct);
    return hit ? { ...l, cust_label: hit.label, cust_code: hit.code || l.cust_code || '' } : l;
  });
}

window.custRefName = custRefName;
window.invoiceCustName = invoiceCustName;
window.stampCustNames = stampCustNames;

// Every client invoice number comes from here: INV + the customer's own order
// reference. With invoicing on dispatch an order normally carries SEVERAL
// invoices, so anything after the first is suffixed — two invoices sharing a
// number is a compliance problem, not a cosmetic one.
function _invNoFor(so, state) {
  const used = [];
  ((state && state.sales_orders) || []).forEach(x => {
    (x.invoices || []).forEach(i => used.push(i.no));
    if (x.invoice_no) used.push(x.invoice_no);
  });
  return clientInvoiceNo(so.so_no, used);
}

function buildDispatchInvoice(so, state, dc, currentUser, getUser, getProduct) {
  if (!dc || !Array.isArray(dc.items) || !dc.items.length) return null;
  // One challan, one invoice. Re-opening a dispatch must never re-bill it.
  if ((so.invoices || []).some(i => i.dc_id === dc.id)) return null;
  if ((so.invoices || []).length === 0 && so.invoice_no) return null;   // legacy direct invoice

  const invoicedSub = (so.invoices || []).filter(i => !i.consolidated).reduce((a, i) => a + (i.subtotal || 0), 0);
  const remainingBilled = Math.max(0, _soBilled(so, state) - invoicedSub);
  if (remainingBilled <= 0.5) return null;

  // What the customer agreed to pay for each item, from this order's own lines.
  const priceOf = {};
  (so.lines || []).forEach(l => (l.components || []).forEach(c => {
    if (priceOf[c.product_id] != null) return;
    const p = getProduct(c.product_id);
    priceOf[c.product_id] = (typeof window.compSellOf === 'function')
      ? window.compSellOf(c, p) : (p ? (Number(p.sell) || 0) : 0);
  }));

  const lines = []; const comp = {}; let subtotal = 0;
  dc.items.forEach(it => {
    const qty = Number(it.qty) || 0;
    if (qty <= 0) return;
    const p = getProduct(it.product_id);
    const rate = Number(priceOf[it.product_id]) || 0;
    const fromOrder = invoiceCustName(so, { ref_id: it.product_id }, getProduct);
    const custLabel = it.cust_name || (fromOrder && fromOrder.label) || it.cust_code || '';
    lines.push({
      kind: 'component', ref_id: it.product_id,
      // their wording first; ours only if we never learned theirs
      label: custLabel || (p ? p.name : it.product_id),
      cust_label: custLabel || '',
      cust_code: it.cust_code || (fromOrder && fromOrder.code) || '',
      our_name: p ? p.name : '', our_code: p ? p.code : '',
      qty, unit_price: rate, amount: qty * rate, gst_pct: invoiceLineGstPct(state, it.product_id, null),
    });
    comp[it.product_id] = (comp[it.product_id] || 0) + qty;
    subtotal += qty * rate;
  });
  if (subtotal <= 0.5) return null;   // nothing priced yet — caller explains why

  subtotal = Math.min(Math.round(subtotal), Math.round(remainingBilled));
  const tax = invoiceTaxFromLines(lines, invoiceSameState(so, state));
  const total = subtotal + tax.gst;
  const fully = (remainingBilled - subtotal) <= 0.5;
  const role = (getUser && currentUser) ? (getUser(currentUser)?.role || '') : '';
  const invoice = {
    id: 'inv-' + Date.now() + Math.random().toString(36).slice(2, 5),
    no: _invNoFor(so, state), date: TODAY,
    type: fully ? 'Final' : 'Partial', mode: 'dispatch',
    dc_id: dc.id, dc_no: dc.dc_no || '',
    lines, comp_consumed: comp,
    subtotal, cgst: tax.cgst, sgst: tax.sgst, igst: tax.igst, gst: tax.gst, total, created_by: currentUser || null, role,
  };
  const invoices = [...(so.invoices || []), invoice];
  // Don't force the STORED status to 'Invoiced' while the client review is
  // still open — see soReviewStillOpen() in utils.jsx. Matters here because
  // invoice_on_dispatch fires the instant goods leave, potentially before
  // anyone has looked at what arrived.
  const nextSO = { ...so, invoices, invoice_no: invoice.no, invoice_date: TODAY,
                   invoice_amount: _nonConsolidatedTotal(invoices),
                   status: (fully && !soReviewStillOpen(state, so)) ? 'Invoiced' : so.status };
  return { so: nextSO, invoice, fully };
}

// ===========================================================================
// Invoicing a BOQ
// ===========================================================================
// A BOQ bills as one invoice, the moment its LAST item is dispatched. Not
// before: seven of ten items out is nothing to bill, because the customer
// agreed to be billed for the ten.
//
// This is the same document as any other client invoice — same numbering, same
// customer wording, same cap — scoped to the BOQ's own lines.
function buildBoqInvoice(so, state, boq, currentUser, getUser, getProduct) {
  if (!boq || !(boq.items || []).length) return null;
  if (boq.invoice_no) return null;                                  // already billed
  if ((so.invoices || []).some(i => i.boq_id === boq.id)) return null;
  if ((so.invoices || []).length === 0 && so.invoice_no) return null;   // legacy direct invoice

  const invoicedSub = (so.invoices || []).filter(i => !i.consolidated).reduce((a, i) => a + (i.subtotal || 0), 0);
  const remainingBilled = Math.max(0, _soBilled(so, state) - invoicedSub);
  if (remainingBilled <= 0.5) return null;

  // Priced from the order's own lines, per (line, item) — the same component the
  // BOM editor prices and the profit panel reads.
  const lines = []; const comp = {}; let subtotal = 0;
  (boq.items || []).forEach(it => {
    const q = Number(it.qty) || 0;
    if (q <= 0) return;
    const l = (so.lines || []).find(x => x.id === it.line_id);
    const c = l && (l.components || []).find(x => x.product_id === it.product_id);
    const p = getProduct(it.product_id);
    const rate = c ? ((typeof window.compSellOf === 'function') ? window.compSellOf(c, p) : (Number(c.sell) || 0)) : 0;
    // This BOQ item names its own order line, so take THAT line's wording first.
    // The order-wide scan is the fallback, and it would pick whichever line
    // mentions the product first — wrong when a product sits in two bundles
    // under two different customer descriptions.
    const cn = (c && custRefName(c.customer_ref))
            || invoiceCustName(so, { ref_id: it.product_id }, getProduct);
    lines.push({
      kind: 'component', ref_id: it.product_id,
      label: (cn && cn.label) || (p ? p.name : it.product_id),
      cust_label: cn ? cn.label : '', cust_code: cn ? cn.code : '',
      our_name: p ? p.name : '', our_code: p ? p.code : '',
      qty: q, unit_price: rate, amount: q * rate, gst_pct: invoiceLineGstPct(state, it.product_id, null),
    });
    comp[it.product_id] = (comp[it.product_id] || 0) + q;
    subtotal += q * rate;
  });
  if (subtotal <= 0.5) return null;      // nothing priced — the caller explains

  subtotal = Math.min(Math.round(subtotal), Math.round(remainingBilled));
  const tax = invoiceTaxFromLines(lines, invoiceSameState(so, state));
  const total = subtotal + tax.gst;
  const fully = (remainingBilled - subtotal) <= 0.5;
  const role = (getUser && currentUser) ? (getUser(currentUser)?.role || '') : '';
  const invoice = {
    id: 'inv-' + Date.now() + Math.random().toString(36).slice(2, 5),
    no: _invNoFor(so, state), date: TODAY,
    type: fully ? 'Final' : 'Partial', mode: 'boq',
    boq_id: boq.id, boq_no: boq.no,
    lines, comp_consumed: comp,
    subtotal, cgst: tax.cgst, sgst: tax.sgst, igst: tax.igst, gst: tax.gst, total, created_by: currentUser || null, role,
  };
  const invoices = [...(so.invoices || []), invoice];
  const nextSO = {
    ...so, invoices, invoice_no: invoice.no, invoice_date: TODAY,
    invoice_amount: _nonConsolidatedTotal(invoices),
    // See soReviewStillOpen() in utils.jsx — a BOQ can complete (and bill) at
    // dispatch time, same as the direct dispatch-invoice path.
    status: (fully && !soReviewStillOpen(state, so)) ? 'Invoiced' : so.status,
    // Stamp the invoice onto the BOQ so it can never bill twice.
    extra: { ...(so.extra || {}), boqs: ((so.extra && so.extra.boqs) || []).map(
      b => b.id === boq.id ? { ...b, invoice_no: invoice.no, invoice_id: invoice.id, invoiced_date: TODAY } : b) },
  };
  return { so: nextSO, invoice, fully };
}

// The FINAL invoice, once every BOQ on the order has fired.
//
// BOQs need not cover the whole order — Purchase may leave units out of every
// billing group. When the last BOQ has been invoiced, whatever else has actually
// been DISPATCHED and never billed is swept into one closing invoice. Goods
// still sitting in the godown are not in it; nothing is billed before it ships,
// which is the same rule every other invoice on this order follows.
//
// If the BOQs did cover everything, there is no remainder and the last BOQ
// invoice is itself already typed 'Final'. Nothing extra is raised.
function buildBoqFinalInvoice(so, state, currentUser, getUser, getProduct) {
  const boqs = soBoqs(so).filter(b => b.status !== 'Cancelled');
  if (!boqs.length) return null;
  if (!boqs.every(b => !!b.invoice_no)) return null;        // a BOQ is still open
  if ((so.invoices || []).some(i => i.mode === 'boq-final')) return null;

  const invoicedSub = (so.invoices || []).filter(i => !i.consolidated).reduce((a, i) => a + (i.subtotal || 0), 0);
  const remainingBilled = Math.max(0, _soBilled(so, state) - invoicedSub);
  if (remainingBilled <= 0.5) return null;                  // order fully billed already

  // Dispatched, minus everything already invoiced (BOQ or challan).
  const left = { ...(window.boqDispatched ? window.boqDispatched(state, so) : {}) };
  (so.invoices || []).filter(i => !i.consolidated).forEach(i => {
    Object.entries(i.comp_consumed || {}).forEach(([pid, q]) => {
      left[pid] = (left[pid] || 0) - (Number(q) || 0);
    });
  });

  const lines = []; const comp = {}; let subtotal = 0;
  (window.boqOrderRows ? window.boqOrderRows(so) : []).forEach(r => {
    const take = Math.min(r.qty, Math.max(0, left[r.product_id] || 0));
    if (take <= 0.0001) return;
    left[r.product_id] -= take;
    const l = (so.lines || []).find(x => x.id === r.line_id);
    const c = l && (l.components || []).find(x => x.product_id === r.product_id);
    const p = getProduct(r.product_id);
    const rate = c ? ((typeof window.compSellOf === 'function') ? window.compSellOf(c, p) : (Number(c.sell) || 0)) : 0;
    const cn = invoiceCustName(so, { ref_id: r.product_id }, getProduct);
    lines.push({
      kind: 'component', ref_id: r.product_id,
      label: (cn && cn.label) || (p ? p.name : r.product_id),
      cust_label: cn ? cn.label : '', cust_code: cn ? cn.code : '',
      our_name: p ? p.name : '', our_code: p ? p.code : '',
      qty: take, unit_price: rate, amount: take * rate, gst_pct: invoiceLineGstPct(state, r.product_id, null),
    });
    comp[r.product_id] = (comp[r.product_id] || 0) + take;
    subtotal += take * rate;
  });
  if (subtotal <= 0.5) return null;         // nothing dispatched outside the BOQs

  subtotal = Math.min(Math.round(subtotal), Math.round(remainingBilled));
  const tax = invoiceTaxFromLines(lines, invoiceSameState(so, state));
  const total = subtotal + tax.gst;
  const fully = (remainingBilled - subtotal) <= 0.5;
  const role = (getUser && currentUser) ? (getUser(currentUser)?.role || '') : '';
  const invoice = {
    id: 'inv-' + Date.now() + Math.random().toString(36).slice(2, 5),
    no: _invNoFor(so, state), date: TODAY,
    type: fully ? 'Final' : 'Partial', mode: 'boq-final',
    lines, comp_consumed: comp,
    subtotal, cgst: tax.cgst, sgst: tax.sgst, igst: tax.igst, gst: tax.gst, total, created_by: currentUser || null, role,
  };
  const invoices = [...(so.invoices || []), invoice];
  // See soReviewStillOpen() in utils.jsx.
  return { so: { ...so, invoices, invoice_no: invoice.no, invoice_date: TODAY,
                 invoice_amount: _nonConsolidatedTotal(invoices),
                 status: (fully && !soReviewStillOpen(state, so)) ? 'Invoiced' : so.status },
           invoice, fully, final: true };
}

// Bill every BOQ that has become complete. Runs after a dispatch, and from the
// button on the BOQ panel.
//
// Computed INSIDE the updater against the latest state, and one BOQ at a time,
// because each invoice changes what is left to bill for the next.
function invoiceReadyBoqs(soId, ctx) {
  const { mutate, currentUser, getUser, getProduct } = ctx;
  const made = [];
  mutate(s => {
    let so = (s.sales_orders || []).find(x => x.id === soId);
    if (!so) return s;
    let notes = [];
    for (let guard = 0; guard < 50; guard++) {
      const ready = (window.boqProgress ? window.boqProgress(s, so) : []).filter(b => b.readyToInvoice);
      if (!ready.length) break;
      // _invNoFor reads state.sales_orders, not `so` directly — it has to see
      // every invoice already minted earlier in THIS SAME run, or two BOQs
      // completing from one dispatch mint the identical invoice number.
      const built = buildBoqInvoice(so, { ...s, sales_orders: s.sales_orders.map(x => x.id === soId ? so : x) }, ready[0], currentUser, getUser, getProduct);
      if (!built) break;                       // unpriced, or nothing left to bill
      so = built.so;
      made.push(built);
      notes.push({
        id: 'n-boqinv-' + Date.now() + made.length, kind: 'invoice',
        text: `${built.invoice.no} (${built.invoice.type}) raised for ${ready[0].no} · ${so.so_no} · ${inrK(built.invoice.total)}${built.fully ? ' · order fully invoiced' : ''}`,
        date: TODAY, read: false, role: 'Collections',
      });
    }
    // Every BOQ has fired -> close the order out with whatever else shipped.
    const closing = buildBoqFinalInvoice(so, { ...s, sales_orders: s.sales_orders.map(x => x.id === soId ? so : x) }, currentUser, getUser, getProduct);
    if (closing) {
      so = closing.so;
      made.push(closing);
      notes.push({
        id: 'n-boqfin-' + Date.now(), kind: 'invoice',
        text: `${closing.invoice.no} (Final) raised on ${so.so_no} · ${inrK(closing.invoice.total)} — all BOQs billed`,
        date: TODAY, read: false, role: 'Collections',
      });
    }

    if (!made.length) return s;
    const finalSO = so;
    return {
      ...s,
      sales_orders: s.sales_orders.map(x => x.id === soId ? finalSO : x),
      notifications: [...notes.reverse(), ...s.notifications],
    };
  }, { action: 'boq-invoice', entity: 'SalesOrder', entity_id: soId,
       detail: `BOQ billing run` });
  return made;
}

window.buildBoqInvoice = buildBoqInvoice;
window.buildBoqFinalInvoice = buildBoqFinalInvoice;
window.invoiceReadyBoqs = invoiceReadyBoqs;

// A BOQ organization that also wants every dispatch billed right away — not
// only once a whole billing group ships. Gated on `boq_partial_on_dispatch`;
// off everywhere else, where invoiceReadyBoqs() above is what runs instead
// and this is never called. See docs/boq-billing.md.
//
// Bills the dispatch itself exactly like a non-BOQ order already does
// (buildDispatchInvoice, unchanged) — "partial" means the same thing it
// always has. Separately, any BOQ that becomes fully dispatched as a
// result gets its own ₹0 confirmation record, stamped the same way a
// completed BOQ always has been (boq.invoice_no) — real, separate invoice
// data to report on, never a second charge for goods the partial already
// billed.
function raiseBoqPartialAndCompletion(soId, dc, ctx) {
  const { mutate, currentUser, getUser, getProduct } = ctx;
  const result = { partial: null, completions: [] };
  if (typeof wfOn === 'function' && !wfOn('boq_partial_on_dispatch')) return result;
  mutate(s => {
    let so = (s.sales_orders || []).find(x => x.id === soId);
    if (!so) return s;
    const notes = [];
    // _invNoFor reads state.sales_orders, not `so` directly — every number
    // minted below has to see what was minted earlier in THIS SAME run.
    const liveState = () => ({ ...s, sales_orders: s.sales_orders.map(x => x.id === soId ? so : x) });

    const built = buildDispatchInvoice(so, liveState(), dc, currentUser, getUser, getProduct);
    if (built) {
      // Which BOQ(s), if any, this delivery's items belong to — informational
      // only, so a partial can be filtered by billing group before its BOQ
      // ever completes. Never affects what gets billed or how much.
      const boqNos = new Set();
      (window.soBoqs ? window.soBoqs(built.so) : []).forEach(b => {
        if (b.status === 'Cancelled') return;
        if ((b.items || []).some(it => dc.items.some(di => di.product_id === it.product_id))) boqNos.add(b.no);
      });
      const invoice = boqNos.size ? { ...built.invoice, boq_nos: Array.from(boqNos) } : built.invoice;
      so = { ...built.so, invoices: built.so.invoices.map(i => i.id === invoice.id ? invoice : i) };
      result.partial = { so, invoice, fully: built.fully };
      notes.push({
        id: 'n-dcinv-' + Date.now(), kind: 'invoice',
        text: `${invoice.no} (${invoice.type}) raised for ${dc.dc_no || 'dispatch'} · ${so.so_no} · ${inrK(invoice.total)}`,
        date: TODAY, read: false, role: 'Collections',
      });
    }

    // Any BOQ that is now fully dispatched but has no completion record yet.
    (window.boqProgress ? window.boqProgress(s, so) : []).forEach(b => {
      if (!b.readyToInvoice) return;
      const boq = ((so.extra && so.extra.boqs) || []).find(x => x.id === b.id);
      if (!boq) return;
      const invoice = {
        id: 'inv-' + Date.now() + Math.random().toString(36).slice(2, 5) + boq.id,
        no: _invNoFor(so, liveState()), date: TODAY,
        type: 'BOQ Complete', mode: 'boq-completion',
        boq_id: boq.id, boq_no: boq.no,
        lines: [], comp_consumed: {},
        subtotal: 0, gst: 0, total: 0, created_by: currentUser || null,
      };
      so = {
        ...so,
        invoices: [...(so.invoices || []), invoice],
        extra: { ...(so.extra || {}), boqs: ((so.extra && so.extra.boqs) || []).map(x => x.id === boq.id ? { ...x, invoice_no: invoice.no } : x) },
      };
      result.completions.push({ so, invoice });
      notes.push({
        id: 'n-boqdone-' + Date.now() + boq.id, kind: 'invoice',
        text: `${boq.no} fully dispatched · completion confirmed (${invoice.no}, ₹0 — already billed via dispatch) · ${so.so_no}`,
        date: TODAY, read: false, role: 'Collections',
      });
    });

    // Defensive, same as invoiceReadyBoqs above — every dispatch here already
    // bills everything it carries, BOQ-member or not, so in practice this is
    // always a no-op once the two loops above have run.
    const closing = buildBoqFinalInvoice(so, liveState(), currentUser, getUser, getProduct);
    if (closing) {
      so = closing.so;
      result.completions.push({ so, invoice: closing.invoice, final: true });
      notes.push({
        id: 'n-boqfin-' + Date.now(), kind: 'invoice',
        text: `${closing.invoice.no} (Final) raised on ${so.so_no} · ${inrK(closing.invoice.total)} — all BOQs billed`,
        date: TODAY, read: false, role: 'Collections',
      });
    }

    if (!notes.length) return s;
    const finalSO = so;
    return {
      ...s,
      sales_orders: s.sales_orders.map(x => x.id === soId ? finalSO : x),
      notifications: [...notes.reverse(), ...s.notifications],
    };
  }, { action: 'boq-partial-dispatch', entity: 'SalesOrder', entity_id: soId,
       detail: `Partial + completion billing run for ${dc.dc_no || dc.id}` });
  return result;
}
window.raiseBoqPartialAndCompletion = raiseBoqPartialAndCompletion;

// Raise it, against the latest state inside the updater.
function raiseDispatchInvoice(soId, dc, ctx) {
  const { mutate, currentUser, getUser, getProduct } = ctx;
  if (typeof wfOn === 'function' && !wfOn('invoice_on_dispatch')) return null;
  let made = null;
  mutate(s => {
    const so = (s.sales_orders || []).find(x => x.id === soId);
    if (!so) return s;
    const built = buildDispatchInvoice(so, s, dc, currentUser, getUser, getProduct);
    if (!built) return s;
    made = built;
    return {
      ...s,
      sales_orders: s.sales_orders.map(x => x.id === soId ? built.so : x),
      notifications: [{
        id: 'n-dinv-' + Date.now(), kind: 'invoice',
        text: `${built.invoice.no} (${built.invoice.type}) raised for ${dc.dc_no || 'dispatch'} · ${so.so_no} · ${inrK(built.invoice.total)}${built.fully ? ' · fully invoiced' : ''}`,
        date: TODAY, read: false, role: 'Collections',
      }, ...s.notifications],
    };
  }, { action: 'invoice-dispatch', entity: 'SalesOrder', entity_id: soId,
       detail: `Invoice raised for ${dc.dc_no || dc.id}` });
  return made;
}

window.buildDispatchInvoice = buildDispatchInvoice;
window.raiseDispatchInvoice = raiseDispatchInvoice;

// Commit an invoice (computed against the latest state inside the updater).
function raiseSOInvoice(soId, opts, ctx, uiOpts) {
  const { mutate, toast, currentUser, getUser, getProduct } = ctx; uiOpts = uiOpts || {};
  let made = null;
  mutate(s => {
    const so = (s.sales_orders || []).find(x => x.id === soId);
    if (!so) return s;
    const built = buildInvoice(so, s, opts || { mode: 'bundle' }, currentUser, getUser, getProduct);
    if (!built) return s;
    made = built;
    return {
      ...s,
      sales_orders: s.sales_orders.map(x => x.id === soId ? built.so : x),
      notifications: [{ id: 'n-inv-' + Date.now(), kind: 'invoice', text: `${built.invoice.no} (${built.invoice.type}) for ${so.so_no} · ${inrK(built.invoice.total)}${built.fully ? ' · fully invoiced' : ''}`, date: TODAY, read: false, role: 'Collections' }, ...s.notifications],
    };
  }, { action: 'invoice', entity: 'SalesOrder', entity_id: soId });
  if (made) { if (toast) toast(`${made.invoice.no} (${made.invoice.type}) · ${inrK(made.invoice.total)}${made.fully ? ' · fully invoiced' : ''}`, 'success'); }
  else if (toast && !uiOpts.silent) toast((opts && opts.mode === 'final') ? 'Nothing left to invoice' : 'Nothing received & un-invoiced for that selection');
  return made;
}

// SO detail → Invoicing tab: live fulfilment + partial/final invoices, billed by
// BUNDLE or by COMPONENT (user's choice). Both reconcile via the component ledger.
function SOInvoicingTab({ so }) {
  const { state, mutate, navigate, currentUser, getUser, getCategory, getProduct, soBilledSubtotal, soBillAdjustment } = useStore();
  const toast = useToast();
  const role = currentUser ? getUser(currentUser)?.role : '';
  const canInvoice = ['Purchase', 'Billing', 'Collections', 'Org Admin'].includes(role);
  const [mode, setMode] = React.useState('bundle');
  const [bundleSel, setBundleSel] = React.useState({});
  const [compSel, setCompSel] = React.useState({});
  const ctx = { mutate, toast, currentUser, getUser, getProduct };

  const st = soInvoiceState(so, state, getProduct);
  const comps = soComponentState(so, state, getProduct);
  const invoices = so.invoices || [];
  const billed = soBilledSubtotal(so) + soImplCharge(so);   // supply + implementation (rate × logged hours)
  const legacyInvoiced = invoices.length === 0 && so.invoice_no;
  const invoicedSub = legacyInvoiced ? billed : invoices.filter(i => !i.consolidated).reduce((a, i) => a + (i.subtotal || 0), 0);
  const remaining = legacyInvoiced ? 0 : Math.max(0, billed - invoicedSub);
  const invoiceableBundles = st.reduce((a, x) => a + x.invoiceableNow, 0);
  const invoiceableComps = comps.reduce((a, x) => a + x.invoiceable, 0);

  return (
    <div className="stack">
      <div className="mb-2" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>
        <div className="card"><div className="card-body" style={{ textAlign: 'center' }}><div className="tiny muted">Billed value</div><div style={{ fontSize: 18, fontWeight: 700 }} className="mono">{inr(billed)}</div>{soBillAdjustment(so) > 0 && <div className="tiny muted">after −{inr(soBillAdjustment(so))} removed</div>}</div></div>
        <div className="card"><div className="card-body" style={{ textAlign: 'center' }}><div className="tiny muted">Invoiced so far</div><div style={{ fontSize: 18, fontWeight: 700 }} className="mono">{inr(invoicedSub)}</div><div className="tiny muted">{invoices.length} invoice(s)</div></div></div>
        <div className="card" style={{ borderColor: remaining > 0 ? 'oklch(0.85 0.09 75)' : 'oklch(0.85 0.06 155)' }}><div className="card-body" style={{ textAlign: 'center', background: remaining > 0 ? 'var(--warning-bg)' : 'var(--success-bg)' }}><div className="tiny muted">Balance to invoice</div><div style={{ fontSize: 18, fontWeight: 700 }} className="mono">{inr(remaining)}</div></div></div>
      </div>

      {legacyInvoiced && <div className="card"><div className="card-body small muted">This SO was invoiced via a single full invoice ({so.invoice_no}). Partial invoicing is closed.</div></div>}

      {canInvoice && remaining > 0.5 && (
        <div className="card">
          <div className="card-header">
            <h3 className="card-title">Raise invoice</h3>
            <div className="tabs" style={{ border: 'none' }}>
              <button className={`tab ${mode === 'bundle' ? 'active' : ''}`} onClick={() => setMode('bundle')}>By bundle</button>
              <button className={`tab ${mode === 'component' ? 'active' : ''}`} onClick={() => setMode('component')}>By component</button>
            </div>
          </div>
          <div className="card-body flush">
            {mode === 'bundle' ? (
              <table className="t">
                <thead><tr><th>Bundle</th><th className="num">Ordered</th><th className="num">Invoiceable now</th><th className="num">Invoice qty</th><th className="num">Amount</th></tr></thead>
                <tbody>
                  {st.map(x => { const cat = getCategory(x.category_id) || { name: x.category_id }; const q = bundleSel[x.line_id] != null ? bundleSel[x.line_id] : x.invoiceableNow; return (
                    <React.Fragment key={x.line_id}>
                      <tr>
                        <td>{cat.name}</td><td className="num">{x.ordered}</td>
                        <td className="num">{x.invoiceableNow}</td>
                        <td className="num"><input type="number" min="0" max={x.invoiceableNow} className="input mono" value={q} onChange={e => setBundleSel(m => ({ ...m, [x.line_id]: e.target.value }))} style={{ width: 64, textAlign: 'right', height: 24 }}/></td>
                        <td className="num mono">{inr(Math.max(0, Math.min(Number(q) || 0, x.invoiceableNow)) * x.unit_price)}</td>
                      </tr>
                      {x.blockedBy && x.blockedBy.length > 0 && (
                        // A priced, real-value bundle can still invoice 0 because
                        // ONE missing component blocks the whole set — correct
                        // (never bill an incomplete bundle), but "0" alone looks
                        // identical to a dead end. Name exactly what is short.
                        <tr>
                          <td colSpan="5" className="tiny" style={{ color: 'var(--warning)', paddingTop: 0 }}>
                            Waiting on {x.blockedBy.map(b => `${b.name} (${qty(b.have)} of ${qty(b.need)} per unit received)`).join(', ')} — nothing bills until every component in the bundle has arrived.
                          </td>
                        </tr>
                      )}
                    </React.Fragment>
                  ); })}
                </tbody>
              </table>
            ) : (
              <table className="t">
                <thead><tr><th>Component</th><th className="num">Received</th><th className="num">Invoiced</th><th className="num">Invoiceable</th><th className="num">Invoice qty</th><th className="num">Amount</th></tr></thead>
                <tbody>
                  {comps.map(r => { const q = compSel[r.product_id] != null ? compSel[r.product_id] : r.invoiceable; return (
                    <tr key={r.product_id}>
                      <td>{r.product ? r.product.name : r.product_id}<div className="tiny muted mono">@ {inr(r.sell)}</div></td>
                      <td className="num">{r.received}</td><td className="num">{r.invoiced}</td><td className="num">{r.invoiceable}</td>
                      <td className="num"><input type="number" min="0" max={r.invoiceable} className="input mono" value={q} onChange={e => setCompSel(m => ({ ...m, [r.product_id]: e.target.value }))} style={{ width: 64, textAlign: 'right', height: 24 }}/></td>
                      <td className="num mono">{inr(Math.max(0, Math.min(Number(q) || 0, r.invoiceable)) * r.sell)}</td>
                    </tr>
                  ); })}
                </tbody>
              </table>
            )}
          </div>
          <div className="card-body" style={{ display: 'flex', gap: 8, alignItems: 'center', borderTop: '1px solid var(--border)' }}>
            <div className="grow tiny muted">{mode === 'bundle' ? `${invoiceableBundles} bundle(s) ready` : `${invoiceableComps} component unit(s) ready`} · partial bills your selection; final bills the whole balance.</div>
            <button className="btn" disabled={mode === 'bundle' ? invoiceableBundles === 0 : invoiceableComps === 0} onClick={() => { raiseSOInvoice(so.id, { mode, selections: mode === 'bundle' ? bundleSel : compSel }, ctx); setBundleSel({}); setCompSel({}); }}><Icon name="receipt" size={13}/>Create partial invoice</button>
            <button className="btn btn-primary" onClick={() => raiseSOInvoice(so.id, { mode: 'final' }, ctx)}><Icon name="receipt" size={13}/>Final / full invoice</button>
          </div>
        </div>
      )}

      <div className="card">
        <div className="card-header"><h3 className="card-title">Invoices raised</h3></div>
        <div className="card-body flush">
          {invoices.length === 0 && legacyInvoiced ? (
            <table className="t"><thead><tr><th>Invoice</th><th>Type</th><th>Date</th><th className="num">Total</th></tr></thead>
              <tbody><tr><td className="mono">{so.invoice_no}</td><td><span className="badge success dot">Full</span></td><td className="mono small">{fmtDate(so.invoice_date)}</td><td className="num"><strong>{inr(so.invoice_amount)}</strong></td></tr></tbody>
            </table>
          ) : invoices.length === 0 && billed <= 0.5 ? (
            // "Balance to invoice" being ₹0 here does NOT mean the order is
            // settled -- it means nothing on it has ever been priced, so
            // buildDispatchInvoice/buildBoqInvoice/buildInvoice all correctly
            // refuse to raise a ₹0 invoice (see dispatch-invoicing.md's "no
            // invoice, not a zero-rupee one"). The previous message here said
            // "auto-appear as material is received" unconditionally, which is
            // both wrong for an org that invoices on DISPATCH
            // (invoice_on_dispatch) and gave no reason or next step for an
            // order that had already been dispatched with nothing to show
            // for it — exactly what made this look like a bug rather than
            // the missing-price data problem it actually is.
            <div className="empty">
              <div className="tiny" style={{ color: 'var(--warning)' }}>
                Nothing to invoice yet — these items have no price set. Set unit
                prices in <strong>Edit line items</strong>, and {wfOn('invoice_on_dispatch')
                  ? 'the next dispatch will invoice automatically.'
                  : 'the next receipt will invoice automatically.'}
              </div>
            </div>
          ) : invoices.length === 0 ? (
            <div className="empty">No invoices yet. Partial invoices auto-appear as material is {wfOn('invoice_on_dispatch') ? 'dispatched' : 'received'}; or raise one above.</div>
          ) : (
            <table className="t">
              <thead><tr><th>Invoice</th><th>Type</th><th className="num">Qty</th><th>Date</th><th className="num">Subtotal</th><th className="num">Total</th></tr></thead>
              <tbody>
                {invoices.map(inv => (
                  <tr key={inv.id} onClick={() => navigate(`invoices/${so.id}/${inv.id}`)} style={{ cursor: 'pointer' }}>
                    <td className="mono">{inv.no}</td>
                    <td>{inv.consolidated ? <span className="badge success dot">Consolidated</span> : inv.type === 'Final' ? <span className="badge success dot">Final</span> : <span className="badge accent dot">Partial</span>}</td>
                    <td className="num mono">{(inv.lines || []).reduce((a, l) => a + (Number(l.qty) || 0), 0) || '—'}</td>
                    <td className="mono small">{fmtDate(inv.date)}</td>
                    <td className="num">{inr(inv.subtotal)}</td>
                    <td className="num"><strong>{inr(inv.total)}</strong>{inv.consolidated && <div className="tiny muted">summary</div>}</td>
                  </tr>
                ))}
              </tbody>
              <tfoot><tr><td colSpan="5" className="right small">Total billed (excl. consolidated)</td><td className="num mono"><strong>{inr(_nonConsolidatedTotal(invoices))}</strong></td></tr></tfoot>
            </table>
          )}
        </div>
      </div>
    </div>
  );
}

// SO detail → GRN tab: this SO's GRNs, an invoice-creation button, and the
// client invoices (partial + consolidated), each opening in the shareable template.
function SOGrnTab({ so }) {
  const { state, navigate, mutate, currentUser, getUser, getProduct } = useStore();
  const toast = useToast();
  const role = currentUser ? getUser(currentUser)?.role : '';
  const canInvoice = ['Purchase', 'Billing', 'Collections', 'Project Manager', 'Org Admin'].includes(role);
  const soPOs = (state.vendor_pos || []).filter(p => poServesSO(p, so.id));
  const soPoIds = new Set(soPOs.map(p => p.id));
  const grns = (state.grns || []).filter(g => soPoIds.has(g.po_id));
  const invoices = so.invoices || [];
  const frac = window.soBundleFraction ? window.soBundleFraction(so, state) : {};
  const overallFrac = (so.lines || []).length ? (so.lines.reduce((a, l) => a + (frac[l.id] || 0), 0) / so.lines.length) : 0;
  const createInvoice = () => {
    const made = window.autoInvoiceSO ? window.autoInvoiceSO(so.id, { mutate, toast: null, currentUser, getUser, getProduct }) : null;
    toast(made ? `${made.invoice.no} created (${made.invoice.type})` : 'Nothing new to invoice yet — receive more items first', made ? 'success' : '');
  };
  return (
    <div className="stack">
      <div className="mb-2" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10 }}>
        <div className="card"><div className="card-body" style={{ textAlign: 'center' }}><div className="tiny muted">GRNs posted</div><div style={{ fontSize: 18, fontWeight: 700 }} className="mono">{grns.length}</div></div></div>
        <div className="card"><div className="card-body" style={{ textAlign: 'center' }}><div className="tiny muted">Received (by item count)</div><div style={{ fontSize: 18, fontWeight: 700 }} className="mono">{Math.round(overallFrac * 100)}%</div></div></div>
        <div className="card"><div className="card-body" style={{ textAlign: 'center' }}><div className="tiny muted">Client invoices</div><div style={{ fontSize: 18, fontWeight: 700 }} className="mono">{invoices.length}</div></div></div>
      </div>
      {canInvoice && (
        <div className="card"><div className="card-body" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <div className="grow"><strong className="small">Create client invoice for received items</strong><div className="tiny muted">Bills the newly-received fraction (e.g. 0.8 then 0.2); the consolidated qty-1 final auto-creates once everything is in. Saved here and in the Invoicing tab.</div></div>
          <button className="btn btn-primary" onClick={createInvoice}><Icon name="receipt" size={13}/>Create client invoice</button>
        </div></div>
      )}
      <div className="card">
        <div className="card-header"><h3 className="card-title">GRNs for this SO</h3></div>
        <div className="card-body flush">
          {grns.length === 0 ? <div className="empty">No GRNs yet — receive items in the Virtual Godown.</div> : (
            <table className="t">
              <thead><tr><th>GRN</th><th>Vendor PO</th><th>Date</th><th className="num">Lines</th><th></th></tr></thead>
              <tbody>{grns.map(g => { const po = soPOs.find(p => p.id === g.po_id); return (
                <tr key={g.id} onClick={() => navigate(`grn/${g.id}`)} style={{ cursor: 'pointer' }}>
                  <td className="mono">{g.grn_no}</td><td className="mono small">{po ? po.po_no : ''}</td><td className="mono small">{fmtDate(g.date)}</td><td className="num">{(g.items || []).length}</td><td><Icon name="chevronRight" size={12}/></td>
                </tr>); })}</tbody>
            </table>
          )}
        </div>
      </div>
      <div className="card">
        <div className="card-header"><h3 className="card-title">Client Invoices</h3><span className="card-sub">partial + consolidated · click to open the template</span></div>
        <div className="card-body flush">
          {invoices.length === 0 ? <div className="empty">No invoices yet.</div> : (
            <table className="t">
              <thead><tr><th>Invoice</th><th>Type</th><th className="num">Qty</th><th>Date</th><th className="num">Total</th></tr></thead>
              <tbody>{invoices.map(inv => (
                <tr key={inv.id} onClick={() => navigate(`invoices/${so.id}/${inv.id}`)} style={{ cursor: 'pointer' }}>
                  <td className="mono">{inv.no}</td>
                  <td>{inv.consolidated ? <span className="badge success dot">Consolidated</span> : inv.type === 'Final' ? <span className="badge success dot">Final</span> : <span className="badge accent dot">Partial</span>}</td>
                  <td className="num mono">{(inv.lines || []).reduce((a, l) => a + (Number(l.qty) || 0), 0)}</td>
                  <td className="mono small">{fmtDate(inv.date)}</td>
                  <td className="num"><strong>{inr(inv.total)}</strong>{inv.consolidated && <div className="tiny muted">summary</div>}</td>
                </tr>))}</tbody>
              <tfoot><tr><td colSpan="4" className="right small">Billed (excl. consolidated)</td><td className="num mono"><strong>{inr(_nonConsolidatedTotal(invoices))}</strong></td></tr></tfoot>
            </table>
          )}
        </div>
      </div>
    </div>
  );
}

// Auto client invoicing on receipt: raise the item-count fractional partial
// (0.8, then 0.2…), then — if the order is now fully received & billed — raise
// the consolidated qty-1 final. Both are separate, real, shareable invoices.
function autoInvoiceSO(soId, ctx) {
  // Some companies invoice outside this system — Microlink among them — and say
  // so with auto_invoice_on_grn = false. Receiving goods was raising a customer
  // tax invoice for them anyway, which is money out of the door on a workflow
  // that explicitly asked for none.
  //
  // Only the AUTOMATIC path is gated. Billing raising an invoice by hand still
  // works exactly as before; this is about what happens without being asked.
  if (typeof wfOn === 'function' && !wfOn('auto_invoice_on_grn')) return null;
  const made = raiseSOInvoice(soId, { mode: 'itemfraction' }, ctx, { silent: true });
  // Always attempt the main/final invoice. Report it when the partial had nothing
  // new to bill (e.g. everything was already invoiced) so callers don't say
  // "nothing to invoice" after the main invoice was in fact created.
  const final = raiseSOInvoice(soId, { mode: 'consolidated' }, ctx, { silent: true });
  return made || final;
}

window.soBundleFraction = soBundleFraction;
window.soInvoiceState = soInvoiceState;
window.raiseSOInvoice = raiseSOInvoice;
window.autoInvoiceSO = autoInvoiceSO;
window.SOGrnTab = SOGrnTab;
window.SOInvoicingTab = SOInvoicingTab;
window.InvoiceList = InvoiceList;
window.InvoiceDetail = InvoiceDetail;
window.CollectionsDashboard = CollectionsDashboard;
