// ============================================================================
// LEON Operations Hub — shared UI primitives
// ============================================================================
const { useState, useEffect, useMemo, useRef, useCallback } = React;

function Badge({ children, tone }) {
  const tones = {
    neutral: 'bg-[var(--leon-line)] text-[var(--leon-black)]',
    brown: 'bg-[var(--leon-brown)] text-white',
    green: 'bg-[#e7f3e9] text-[#3a7d44]',
    yellow: 'bg-[#fbf1dd] text-[#a67b1f]',
    red: 'bg-[#fbe7e7] text-[#b83b3b]',
    blue: 'bg-[#e3edfa] text-[#2563a8]',
    black: 'bg-[var(--leon-black)] text-white',
  };
  return (
    <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-semibold uppercase tracking-wide ${tones[tone] || tones.neutral}`}>
      {children}
    </span>
  );
}

function statusTone(status) {
  if (status === 'Completed Job') return 'black';
  if (['Completed', 'Approved', 'Paid', 'Green', 'Active Job', 'Approved/Certified'].includes(status)) return 'green';
  if (['Delayed', 'Overdue', 'Rejected', 'Red', 'Lost Job', 'Voided'].includes(status)) return 'red';
  if (['Due', 'Pending', 'Submitted', 'Revised', 'Yellow', 'In Progress', 'Active Quotation', 'Partially Paid'].includes(status)) return 'yellow';
  if (['Lead', 'Blue', 'Ready for Review'].includes(status)) return 'blue';
  return 'neutral';
}

function StatusBadge({ status }) {
  return <Badge tone={statusTone(status)}>{status === 'Completed Job' && '✓ '}{status}</Badge>;
}

// Basic = 1 star, Medium = 2, High-End = 3 — matches COMPLEXITY_LEVELS' key
// order (data.jsx), shown wherever a complexity badge used to render as
// plain text.
const COMPLEXITY_STAR_COUNTS = { Basic: 1, Medium: 2, 'High-End': 3 };
function ComplexityStars({ level }) {
  const count = COMPLEXITY_STAR_COUNTS[level] || 0;
  // Unfilled positions use the outline glyph (☆), not a faint ★ — a
  // low-opacity filled star was visually indistinguishable from a filled one
  // at this size, making every complexity level look like 3 stars.
  return (
    <span className="inline-flex items-center gap-0.5 text-xs font-semibold" title={level}>
      <span className="text-[var(--leon-brown)]">{'★'.repeat(count)}</span>
      <span className="text-[var(--leon-black)]/35">{'☆'.repeat(3 - count)}</span>
    </span>
  );
}
// Green = no open issues, Yellow = medium (some delay/overdue signals), Red =
// big issues or delays (see computeProjectHealth, lib.jsx). A checkmark vs.
// exclamation communicates that at a glance — a plain colored dot didn't.
function HealthDot({ level, showLabel }) {
  const c = healthDot(level);
  const icon = level === 'Green' ? '✓' : '!';
  const label = (HEALTH[level] && HEALTH[level].label) || level;
  return (
    <span className="inline-flex items-center gap-1.5">
      <span className="inline-flex items-center justify-center w-4 h-4 rounded-full text-white text-[10px] font-bold leading-none shrink-0" style={{ background: c }}>{icon}</span>
      {showLabel && <span className="text-xs font-medium" style={{ color: c }}>{label}</span>}
    </span>
  );
}

function Avatar({ name, size, url }) {
  const s = size || 28;
  if (url) {
    return <img src={url} alt={name} title={name} className="inline-block rounded-full object-cover shrink-0" style={{ width: s, height: s }} />;
  }
  return (
    <span
      className="inline-flex items-center justify-center rounded-full bg-[var(--leon-brown)] text-white font-semibold shrink-0"
      style={{ width: s, height: s, fontSize: s * 0.38 }}
      title={name}
    >
      {initials(name)}
    </span>
  );
}

// `title` is forwarded: dozens of call sites pass one and it was being
// dropped, so every Button tooltip in the app was silently doing nothing.
function Button({ children, onClick, variant, size, disabled, className, type, title }) {
  const variants = {
    primary: 'bg-[var(--leon-brown)] text-white hover:bg-[var(--leon-brown-dark)] disabled:opacity-40',
    outline: 'border border-[var(--leon-black)] text-[var(--leon-black)] hover:bg-[var(--leon-black)] hover:text-white disabled:opacity-40',
    ghost: 'text-[var(--leon-brown)] hover:bg-[var(--leon-line)]',
    danger: 'bg-[var(--leon-red)] text-white hover:opacity-90 disabled:opacity-40',
    black: 'bg-[var(--leon-black)] text-white hover:opacity-90 disabled:opacity-40',
  };
  const sizes = { sm: 'px-2.5 py-1 text-xs', md: 'px-4 py-2 text-sm', lg: 'px-5 py-2.5 text-sm' };
  return (
    <button
      type={type || 'button'}
      onClick={onClick}
      title={title}
      disabled={disabled}
      className={`rounded-md font-semibold transition-colors whitespace-nowrap ${variants[variant || 'primary']} ${sizes[size || 'md']} ${className || ''}`}
    >
      {children}
    </button>
  );
}

function IconBtn({ onClick, title, children, className }) {
  return (
    <button
      onClick={onClick}
      title={title}
      className={`inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-[var(--leon-line)] text-[var(--leon-black)]/70 hover:text-[var(--leon-black)] ${className || ''}`}
    >
      {children}
    </button>
  );
}

function Field({ label, children, hint, className }) {
  return (
    <label className={`block ${className || ''}`}>
      {label && <span className="block text-xs font-semibold text-[var(--leon-black)]/60 uppercase tracking-wide mb-1">{label}</span>}
      {children}
      {hint && <span className="block text-[11px] text-[var(--leon-black)]/40 mt-1">{hint}</span>}
    </label>
  );
}

const inputCls = 'w-full rounded-md border border-[var(--leon-line)] px-3 py-2 text-sm bg-white focus:border-[var(--leon-brown)]';

// forwardRef, because `ref` is not part of props: a plain function component
// silently drops it and the caller's ref stays null. LEON Sheets needs the
// formula bar's own node to put the caret inside a freshly inserted function,
// and any other caller that wants to focus a field needs the same.
const TextInput = React.forwardRef(function TextInput(props, ref) {
  return <input ref={ref} {...props} className={`${inputCls} ${props.className || ''}`} />;
});
function Select(props) {
  return <select {...props} className={`${inputCls} ${props.className || ''}`} />;
}
// Shared sort-order dropdown for list views — pairs with lib.jsx's sortList().
// `options` is [{key,label}]; the caller owns the sortKey state.
function SortSelect({ value, onChange, options, className }) {
  return (
    <Select value={value} onChange={e => onChange(e.target.value)} className={`!w-auto ${className || ''}`}>
      {options.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
    </Select>
  );
}
// Job-title dropdown for person contacts (data.jsx's CONTACT_TITLES) — the
// contact's current title is unioned into the option list when it isn't one
// of the presets, so older freeform titles stay visible instead of quietly
// vanishing behind a closed list.
function TitleSelect({ value, onChange, className, disabled }) {
  const options = value && !CONTACT_TITLES.includes(value) ? [value, ...CONTACT_TITLES] : CONTACT_TITLES;
  return (
    <Select value={value || ''} onChange={e => onChange(e.target.value)} className={className} disabled={disabled}>
      <option value="">Select title…</option>
      {options.map(t => <option key={t} value={t}>{t}</option>)}
    </Select>
  );
}
function TextArea(props) {
  return <textarea {...props} className={`${inputCls} ${props.className || ''}`} />;
}

// `wide` (max-w-2xl) is kept because dozens of call sites pass it. `size` is the
// finer control for the few dialogs that carry a TABLE rather than a form — a
// quote line has fourteen columns and they cannot be read at 672px, which is
// what made the wizard's lines look scrambled.
const MODAL_WIDTHS = { md: 'max-w-md', lg: 'max-w-4xl', xl: 'max-w-6xl', full: 'max-w-[96vw]' };
function Modal({ open, onClose, title, children, footer, wide, size }) {
  if (!open) return null;
  const width = MODAL_WIDTHS[size] || (wide ? 'max-w-2xl' : 'max-w-md');
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center p-4 no-print">
      <div className="absolute inset-0 bg-black/40" onClick={onClose} />
      <div className={`relative bg-white rounded-xl shadow-2xl w-full ${width} max-h-[92vh] overflow-y-auto`}>
        <div className="flex items-center justify-between px-6 py-4 border-b border-[var(--leon-line)] sticky top-0 bg-white z-10">
          <h3 className="font-bold text-lg">{title}</h3>
          <IconBtn onClick={onClose} title="Close">✕</IconBtn>
        </div>
        <div className="px-6 py-5">{children}</div>
        {footer && <div className="px-6 py-4 border-t border-[var(--leon-line)] flex justify-end gap-2 sticky bottom-0 bg-white">{footer}</div>}
      </div>
    </div>
  );
}

// Generic record drill-down (§8, later extended into a global print/export
// standard) — every entry point (Allocations, Releases, Packing Lists,
// Deliveries, Estimates/POs/PIs, Inventory, Tasks, Issues, Change Orders...)
// opens the same shape: a list of {label,value} fields, optional
// attachments, an optional history list, and optional related-record links,
// so "open any record" behaves consistently everywhere it's wired up rather
// than each entity growing its own modal.
// `editable` hides edit-only affordances passed via `children` for
// view-permission-only users, per §8's "viewable but not editable" case.
// `printable` adds a Print/PDF button; `relatedRecords` is an array of
// {label, onClick} rendered as clickable chips using the caller's existing
// ctx.go* navigation, satisfying "click a related record and navigate
// without re-searching."
function RecordDetailModal({ open, onClose, title, subtitle, fields, attachments, history, editable, children, printable, relatedRecords }) {
  return (
    <>
      <Modal open={open} onClose={onClose} wide title={title} footer={
        <>
          {printable && <PrintButton onClick={() => window.print()} label="Print / PDF" />}
          <Button variant="ghost" onClick={onClose}>Close</Button>
        </>
      }>
        <div className="space-y-4">
          {subtitle && <p className="text-xs text-[var(--leon-black)]/50">{subtitle}</p>}
          {relatedRecords && relatedRecords.length > 0 && (
            <div className="flex flex-wrap gap-2">
              {relatedRecords.map((r, i) => (
                <button key={i} onClick={r.onClick} className="no-print text-xs px-2.5 py-1 rounded-full border border-[var(--leon-brown)] text-[var(--leon-brown)] hover:bg-[var(--leon-cream)]">{r.label} →</button>
              ))}
            </div>
          )}
          {fields && fields.length > 0 && (
            <div className="grid sm:grid-cols-2 gap-x-4 gap-y-2">
              {fields.map((f, i) => (
                <div key={i}>
                  <p className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/40">{f.label}</p>
                  <p className="text-sm font-semibold">{f.value === null || f.value === undefined || f.value === '' ? '—' : f.value}</p>
                </div>
              ))}
            </div>
          )}
          {children}
          {attachments && attachments.length > 0 && (
            <div>
              <p className="text-xs font-semibold mb-1">Attachments</p>
              <div className="space-y-1">
                {attachments.map((a, i) => <AttachmentLink key={i} name={a.name} url={a.url} />)}
              </div>
            </div>
          )}
          {history && (
            <div>
              <p className="text-xs font-semibold mb-1">History</p>
              {history.length === 0 ? <p className="text-xs text-[var(--leon-black)]/40">No history yet.</p> : (
                <div className="space-y-2">
                  {[...history].reverse().map((h, i) => (
                    <div key={h.id || i} className="border border-[var(--leon-line)] rounded-lg p-2 text-xs">
                      <p className="font-semibold">{fmtDate(h.date)} {h.time || ''} — {h.user}</p>
                      <p>{h.reason || h.action}{h.quantity != null ? ` · Quantity: ${h.quantity}` : ''}</p>
                      {(h.previousAllocation != null || h.newAllocation != null || h.previousValue != null || h.newValue != null) && (
                        <p className="text-[var(--leon-black)]/50">Previous: {h.previousAllocation ?? h.previousValue ?? '—'} → New: {h.newAllocation ?? h.newValue ?? '—'}</p>
                      )}
                      {h.notes && <p className="text-[var(--leon-black)]/50">{h.notes}</p>}
                    </div>
                  ))}
                </div>
              )}
            </div>
          )}
        </div>
      </Modal>
      {/* Printing while inside a Modal needs its content OUTSIDE the modal's
          own .no-print wrapper (an ancestor display:none hides descendants
          regardless of their own class) — this sibling block is invisible
          on screen (.print-only) and is what @media print actually shows. */}
      {open && printable && (
        <div className="print-only print-area p-8">
          <RecordPrintBlock title={title} subtitle={subtitle} fields={fields} history={history} />
        </div>
      )}
    </>
  );
}
// Shared printable header — company mark + record title + printed date.
// Kept generic (no required Project) so it works for any record type,
// unlike app.jsx's project-bound PrintHeader.
function PrintDocHeader({ title, meta }) {
  return (
    <div className="mb-6 flex items-center justify-between border-b-2 border-black pb-3">
      <div className="flex items-center gap-3">
        <img src="logo/leon-mark.svg" alt="LEON" className="h-20 w-auto" />
        <div>
          <div className="font-brand font-bold text-lg">LEON OPERATIONS HUB</div>
          <div className="text-xs text-gray-500">{COMPANY_PRINT_ADDRESS}</div>
          {meta && <div className="text-sm">{meta}</div>}
        </div>
      </div>
      <div className="text-right">
        <div className="font-bold">{title}</div>
        <div className="text-xs text-gray-500">Printed {fmtDate(todayISO())}</div>
      </div>
    </div>
  );
}
// Printable fields/history block — the print-output twin of RecordDetailModal's
// on-screen content, reused as-is when list-level "print selected" work
// (Phase 3) needs to render one of these per selected record.
function RecordPrintBlock({ title, subtitle, fields, history }) {
  return (
    <div className="mb-8">
      <PrintDocHeader title={title} meta={subtitle} />
      {fields && fields.length > 0 && (
        <table className="w-full text-sm mb-4">
          <tbody>
            {fields.map((f, i) => (
              <tr key={i} className="border-b border-gray-200">
                <td className="py-1 pr-4 font-semibold w-1/3">{f.label}</td>
                <td className="py-1">{f.value === null || f.value === undefined || f.value === '' ? '—' : f.value}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
      {history && history.length > 0 && (
        <div>
          <p className="font-bold text-sm mb-1">History</p>
          <table className="w-full text-xs">
            <tbody>
              {[...history].reverse().map((h, i) => (
                <tr key={i} className="border-b border-gray-200">
                  <td className="py-1 pr-2 whitespace-nowrap">{fmtDate(h.date)} {h.time || ''}</td>
                  <td className="py-1 pr-2 whitespace-nowrap">{h.user}</td>
                  <td className="py-1">{h.reason || h.action}{h.notes ? ` — ${h.notes}` : ''}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}
// Generic CSV export — shared by the Report Builder (reports.jsx) and any
// other list/record that needs "export to CSV" (relocated here from
// reports.jsx so it's reusable app-wide; classic <script> tags share one
// global scope, so this is a straight move, not a new dependency).
function downloadCsv(filename, columns, rows) {
  const header = columns.map(c => csvEscape(c.label)).join(',');
  const body = rows.map(r => columns.map(c => csvEscape(fmtReportValue(c, r[c.key]))).join(',')).join('\n');
  const blob = new Blob([header + '\n' + body], { type: 'text/csv;charset=utf-8;' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}
function csvEscape(v) {
  const s = v === null || v === undefined ? '' : String(v);
  return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

// Shared Attachments + Activity Log block for full-page entity views
// (Account/Vendor/Subcontractor today) — same shape everywhere, so a future
// page adopting this global standard just wires its own add/remove
// functions rather than rebuilding this UI.
function AttachmentsAndActivitySection({ attachments, activityLog, onAdd, onRemove, editable }) {
  return (
    <>
      <Collapsible title="Attachments" count={(attachments || []).length}>
        <div className="space-y-1.5">
          {(attachments || []).map(a => (
            <div key={a.id} className="flex items-center justify-between gap-2 border border-[var(--leon-line)] rounded-lg px-3 py-1.5">
              <div className="min-w-0">
                <AttachmentLink name={a.name} url={a.url} />
                <p className="text-[10px] text-[var(--leon-black)]/40">{a.uploadedBy} · {fmtDate(a.uploadedDate)}</p>
              </div>
              {editable && <IconBtn title="Remove" onClick={() => onRemove(a.id)}>✕</IconBtn>}
            </div>
          ))}
          {(!attachments || attachments.length === 0) && <p className="text-xs text-[var(--leon-black)]/40">No attachments yet.</p>}
          {editable && <div className="no-print mt-1"><FileField name="" url={null} onChange={(fname, url) => fname && onAdd({ name: fname, url })} editable placeholder="+ Add attachment" /></div>}
        </div>
      </Collapsible>
      <Collapsible title="Activity Log" count={(activityLog || []).length}>
        {(!activityLog || activityLog.length === 0) ? <p className="text-xs text-[var(--leon-black)]/40">No activity yet.</p> : (
          <div className="space-y-1">
            {[...activityLog].reverse().map(a => (
              <p key={a.id} className="text-xs text-[var(--leon-black)]/60">{fmtDate(a.date)} {a.time} — {a.user}: {a.action}</p>
            ))}
          </div>
        )}
      </Collapsible>
    </>
  );
}

// ---------------------------------------------------------------------------
// Session-scoped "which sections did the user expand" store — every
// Collapsible below is collapsed by default; expanding one is remembered for
// the rest of this browser tab's session (sessionStorage, separate from the
// app-state/auth-session localStorage keys, so it naturally clears when the
// tab closes). Sections read/write this store directly by id so no plumbing
// through ctx or every call site's props is needed.
// ---------------------------------------------------------------------------
const SECTION_STATE_KEY = 'leon-ops-hub-expanded-sections-v1';
function slugifySectionId(s) {
  return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-+|-+$)/g, '') || 'section';
}
function loadExpandedSections() {
  try {
    const raw = sessionStorage.getItem(SECTION_STATE_KEY);
    return raw ? new Set(JSON.parse(raw)) : new Set();
  } catch (e) { return new Set(); }
}
function saveExpandedSections(set) {
  try { sessionStorage.setItem(SECTION_STATE_KEY, JSON.stringify([...set])); } catch (e) {}
}
// Called by deep-link actions (a dashboard alert, a report drill-down) to
// ensure one specific section opens on arrival, without forcing anything
// else open — everything else is already collapsed by default.
function openSection(id) {
  const set = loadExpandedSections();
  set.add(id);
  saveExpandedSections(set);
  window.dispatchEvent(new CustomEvent('leon-section-open', { detail: { id } }));
}

// `defaultOpen` only decides the FIRST render — once someone opens or closes a
// section their choice is remembered, and that always wins.
function Collapsible({ title, right, children, count, id, defaultOpen, printRegion, printable, printLines }) {
  const sectionId = id || slugifySectionId(title);
  const [open, setOpen] = useState(() => {
    const remembered = loadExpandedSections();
    return remembered.has(sectionId) || (!!defaultOpen && !remembered.has('!' + sectionId));
  });
  useEffect(() => {
    function onOpen(e) { if (e.detail.id === sectionId) setOpen(true); }
    // Expand/collapse all. Every mounted Collapsible answers the same event, so
    // one control opens or closes whatever is on screen — including sections
    // nested inside other sections.
    function onAll(e) {
      const next = e.type === 'leon-sections-expand-all';
      setOpen(next);
      const set = loadExpandedSections();
      if (next) { set.add(sectionId); set.delete('!' + sectionId); }
      else { set.delete(sectionId); set.add('!' + sectionId); }
      saveExpandedSections(set);
    }
    window.addEventListener('leon-section-open', onOpen);
    window.addEventListener('leon-sections-expand-all', onAll);
    window.addEventListener('leon-sections-collapse-all', onAll);
    return () => {
      window.removeEventListener('leon-section-open', onOpen);
      window.removeEventListener('leon-sections-expand-all', onAll);
      window.removeEventListener('leon-sections-collapse-all', onAll);
    };
  }, [sectionId]);
  function toggle() {
    setOpen(o => {
      const next = !o;
      const set = loadExpandedSections();
      // '!id' records a deliberate close, so a defaultOpen section stays shut.
      if (next) { set.add(sectionId); set.delete('!' + sectionId); }
      else { set.delete(sectionId); set.add('!' + sectionId); }
      saveExpandedSections(set);
      return next;
    });
  }
  return (
    <div className="border border-[var(--leon-line)] rounded-lg bg-white mb-3 overflow-hidden" data-print-region>
      <button
        onClick={toggle}
        data-section-head
        className="w-full flex items-center justify-between px-4 py-3 hover:bg-[var(--leon-cream)] text-left"
      >
        <span className="flex items-center gap-2 font-bold text-sm">
          <span className={`chev ${open ? 'open' : ''}`}>▸</span>
          {title}
          {count !== undefined && <span className="text-[var(--leon-black)]/40 font-medium">({count})</span>}
        </span>
        <span onClick={e => e.stopPropagation()} className="flex items-center gap-2">
          {/* Every section can be taken on its own. Icon-only so it stays
              quiet on a screen that may hold a dozen of them. */}
          {printable !== false && (
            <DocActions title={typeof title === 'string' ? title : 'this section'}
              heading={typeof title === 'string' ? title : undefined} lines={printLines} />
          )}
          {right}
        </span>
      </button>
      {open && <div className="px-4 pb-4 pt-1 border-t border-[var(--leon-line)]">{children}</div>}
    </div>
  );
}

// ---------------------------------------------------------------------------
// Global search — projects, accounts, vendors, subcontractors, and (when the
// signed-in role is already allowed to see them elsewhere) AP invoices,
// change orders/back-charges, tasks, and issues. Each record type is only
// added to the index when the current role can already view that data via
// its normal tab, so search can never surface something a direct visit
// wouldn't — reuses the same ctx.canSeeFin/ctx.canView checks as everywhere
// else in the app.
// ---------------------------------------------------------------------------
function GlobalSearch({ ctx }) {
  const [query, setQuery] = useState('');
  const [openDropdown, setOpenDropdown] = useState(false);
  const boxRef = useRef(null);

  useEffect(() => {
    function onClickOutside(e) {
      if (boxRef.current && !boxRef.current.contains(e.target)) setOpenDropdown(false);
    }
    document.addEventListener('mousedown', onClickOutside);
    return () => document.removeEventListener('mousedown', onClickOutside);
  }, []);

  const results = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return [];
    const out = [];
    const push = (type, label, sublabel, onOpen) => out.push({ type, label, sublabel, onOpen });

    ctx.projects.forEach(p => {
      if (p.name.toLowerCase().includes(q) || p.projectNumber.toLowerCase().includes(q)) {
        push('Project', p.name, p.projectNumber, () => ctx.goProject(p.id));
      }
    });

    ctx.accounts.forEach(a => {
      if (a.name.toLowerCase().includes(q)) {
        push('Account', a.name, a.contactName || '', () => ctx.goAccountDetail(a.id));
      }
    });

    ctx.vendors.forEach(v => {
      if (v.name.toLowerCase().includes(q)) {
        push('Vendor', v.name, v.vendorType || '', () => ctx.goVendorDetail(v.id, 'vendor'));
      }
    });

    (ctx.subcontractors || []).forEach(s => {
      if (s.companyName.toLowerCase().includes(q)) {
        push('Subcontractor', s.companyName, s.trade || '', () => ctx.goSubcontractorDetail(s.id));
      }
    });

    if (ctx.canSeeFin) {
      allApInvoices(ctx.projects).forEach(inv => {
        if (inv.invoiceNumber.toLowerCase().includes(q) || inv.vendorName.toLowerCase().includes(q)) {
          push('AP Invoice', inv.invoiceNumber, `${inv.vendorName} · ${inv.projectName}`, () => ctx.goProjectTab(inv.projectId, 'financials', 'ap'));
        }
      });
      ctx.projects.forEach(p => {
        (p.changeOrders || []).forEach(co => {
          if ((co.number || '').toLowerCase().includes(q) || (co.description || '').toLowerCase().includes(q)) {
            const isBc = co.type === 'Back Charge';
            push(isBc ? 'Back-charge' : 'Change Order', co.number || co.type, p.name, () => ctx.goProjectTab(p.id, 'sales', isBc ? 'backCharges' : 'changeOrders'));
          }
        });
      });
    }

    ctx.projects.forEach(p => {
      if (ctx.canView('tasks')) {
        (p.tasks || []).forEach(t => {
          if (t.title.toLowerCase().includes(q)) push('Task', t.title, p.name, () => ctx.goProjectTab(p.id, 'tasks'));
        });
      }
      if (ctx.canView('issues')) {
        (p.issues || []).forEach(i => {
          if (i.title.toLowerCase().includes(q)) push('Issue', i.title, p.name, () => ctx.goProjectTab(p.id, 'issues'));
        });
      }
    });

    return out.slice(0, 30);
  }, [query, ctx.projects, ctx.accounts, ctx.vendors, ctx.subcontractors, ctx.canSeeFin]);

  function select(r) {
    r.onOpen();
    setQuery('');
    setOpenDropdown(false);
  }

  // Width is the caller's business — the header wants a compact box, a mobile
  // row wants the full width — so this no longer pins its own size.
  return (
    <div ref={boxRef} className="relative w-full">
      <TextInput
        placeholder="Search…"
        value={query}
        onChange={e => { setQuery(e.target.value); setOpenDropdown(true); }}
        onFocus={() => setOpenDropdown(true)}
        className="w-full !py-1 !text-xs"
      />
      {openDropdown && query.trim() && (
        <div className="absolute right-0 mt-1 w-[min(20rem,90vw)] max-h-96 overflow-y-auto bg-white border border-[var(--leon-line)] rounded-lg shadow-lg z-40">
          {results.length === 0 ? (
            <div className="px-3 py-3 text-xs text-[var(--leon-black)]/40 italic">No matches.</div>
          ) : results.map((r, i) => (
            <button key={i} onClick={() => select(r)} className="w-full text-left px-3 py-2 hover:bg-[var(--leon-cream)] border-b border-[var(--leon-line)] last:border-0">
              <div className="flex items-center justify-between gap-2">
                <span className="text-sm font-semibold truncate">{r.label}</span>
                <span className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/40 shrink-0">{r.type}</span>
              </div>
              {r.sublabel && <p className="text-xs text-[var(--leon-black)]/50 truncate">{r.sublabel}</p>}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function EmptyState({ text }) {
  return <div className="text-sm text-[var(--leon-black)]/40 italic py-6 text-center">{text}</div>;
}

function Tabs({ tabs, active, onChange }) {
  return (
    <div className="flex gap-1 flex-wrap no-print border-b border-[var(--leon-line)]">
      {tabs.map(t => (
        <button
          key={t.key}
          onClick={() => onChange(t.key)}
          className={`subtab-btn px-3 py-1.5 text-[12px] font-semibold whitespace-nowrap border-b-2 transition-colors ${
            active === t.key
              ? 'border-[var(--leon-brown)] text-[var(--leon-brown)]'
              : 'border-transparent text-[var(--leon-black)]/50 hover:text-[var(--leon-black)]'
          }`}
        >
          {/* Icons are supplementary, never the only label — the text stays,
              so nothing depends on reading a glyph. aria-hidden keeps screen
              readers from announcing the emoji before every tab name. */}
          {t.icon && <span aria-hidden="true" className="mr-1.5 opacity-80">{t.icon}</span>}
          {t.label}
        </button>
      ))}
    </div>
  );
}

function LockedNotice({ label }) {
  return (
    <div className="flex items-center gap-2 text-sm text-[var(--leon-black)]/50 bg-[var(--leon-cream)] border border-dashed border-[var(--leon-line)] rounded-lg px-4 py-6 justify-center">
      <span>🔒</span> {label || 'You do not have permission to view this section.'}
    </div>
  );
}

// The stripe fallback for a job with no photo yet — one definition, so a
// thumbnail and a full-height card band are recognisably the same thing.
const PROJECT_THUMB_STRIPES = 'repeating-linear-gradient(45deg, var(--leon-brown), var(--leon-brown) 6px, var(--leon-brown-dark) 6px, var(--leon-brown-dark) 12px)';
// `size` gives a fixed square; `fill` gives a band that takes the full size of
// whatever holds it, which is what the project card uses so the picture runs the
// height of the card with the information beside it. The caller owns the box in
// fill mode — this only paints inside it.
// Deliberately NOT click-to-zoom. On a project card the thumb sits inside the
// <button> that opens the job, and in the project header there is already an
// explicit 🔍 beside it — a click here means "open this project".
function ProjectThumb({ project, size, fill }) {
  if (fill) {
    return project.displayImageUrl
      ? <img src={project.displayImageUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
      : (
        <div className="absolute inset-0 flex items-center justify-center font-bold text-white text-lg"
          style={{ background: PROJECT_THUMB_STRIPES }}>
          {initials(project.name)}
        </div>
      );
  }
  const s = size || 44;
  if (project.displayImageUrl) {
    return <img src={project.displayImageUrl} alt="" className="rounded-md object-cover shrink-0" style={{ width: s, height: s }} />;
  }
  return (
    <div
      className="rounded-md shrink-0 flex items-center justify-center font-bold text-white"
      style={{ width: s, height: s, fontSize: s * 0.32, background: PROJECT_THUMB_STRIPES }}
    >
      {initials(project.name)}
    </div>
  );
}

// Two behaviours, one button. With `onClick` it does whatever the caller says
// — every existing call site prints the whole page or an open modal, unchanged.
// Without one it prints the nearest enclosing [data-print-region], which is how
// a single job, scope, tab or section is saved on its own (see printRegion).
// The shared look for the small action controls that sit beside a section:
// an icon in a quiet outlined square. Labels are gone on purpose — three
// icons read faster than three sentences, and the words were saying the same
// thing on every screen.
const ICON_BTN_CLASS = 'no-print inline-flex items-center justify-center w-7 h-7 rounded-md border border-[var(--leon-line)] bg-white text-[var(--leon-black)]/60 hover:border-[var(--leon-brown-light)] hover:text-[var(--leon-brown)] transition-colors';
// A standing operational notice — the weekend maintenance window on the sign-in
// screen, the "still in development" line on LEON Studio. ONE component, so the
// two cannot drift into two differently-styled notices, and a third costs an
// entry in data.jsx rather than another block of markup.
function NoticeCard({ notice, className }) {
  if (!notice || !notice.title) return null;
  // A filled brand bar rather than a tinted panel: the first version was a 7%
  // wash that read as decoration and got looked past, which is the one thing a
  // standing notice must not do. The heading is knocked out of solid brown, the
  // body sits on a light ground beneath it, and the whole card is ruled — so it
  // reads as a notice at a glance without shouting in red, which this is not.
  return (
    <div className={'rounded-xl overflow-hidden border-2 border-[var(--leon-brown)] shadow-sm ' + (className || '')}>
      <div className="flex items-center gap-2 px-4 py-2" style={{ background: 'var(--leon-brown)' }}>
        {notice.icon && <span aria-hidden="true" className="text-[14px] leading-none">{notice.icon}</span>}
        <span className="text-white text-[12px] font-bold tracking-[0.14em] uppercase">{notice.title}</span>
      </div>
      <div className="px-4 py-3 bg-[var(--leon-brown)]/[0.06]">
        {(notice.lines || []).map((line, i) => (
          <p key={i} className={'text-[13px] leading-snug text-[var(--leon-black)]/85' + (i ? ' mt-1.5' : '')}>{line}</p>
        ))}
      </div>
    </div>
  );
}

function IconAction({ icon, title, onClick, className, disabled }) {
  return (
    <button type="button" title={title} onClick={onClick} disabled={disabled}
      className={`${ICON_BTN_CLASS} disabled:opacity-40 ${className || ''}`}>
      <span aria-hidden="true" className="text-[13px] leading-none">{icon}</span>
      <span className="sr-only">{title}</span>
    </button>
  );
}
// 🖨 sends it to a printer; 📄 writes a PDF file and downloads it. They are
// different things and used to be the same button, which is why "Save as PDF"
// opened a print dialog.
function DocActions({ title, heading, lines, className, onlyPdf }) {
  const ref = useRef(null);
  const region = () => ref.current && ref.current.closest('[data-print-region]');
  const opts = () => ({ title, heading: heading || title, lines });
  return (
    <span ref={ref} className={`no-print inline-flex items-center gap-1 ${className || ''}`}>
      {!onlyPdf && <IconAction icon="🖨" title={`Print ${title || 'this section'}`} onClick={() => printRegion(region(), opts())} />}
      <IconAction icon="📄" title={`Download ${title || 'this section'} as a PDF`} onClick={() => exportPdf(region(), opts())} />
    </span>
  );
}
// Kept for the call sites that print a whole page or an open modal.
function PrintButton({ onClick, label, title, heading, lines, className }) {
  const ref = useRef(null);
  if (!onClick) return <DocActions title={title} heading={heading} lines={lines} className={className} />;
  return (
    <span ref={ref} className={`inline-flex no-print ${className || ''}`}>
      <Button variant="outline" size="sm" className="no-print" onClick={onClick}>
        🖨{label === '' ? '' : ` ${label || 'Print'}`}
      </Button>
    </span>
  );
}


// ── Looking at a photo properly ─────────────────────────────────────────────
// A photo in this app is evidence — a finished room, a QC failure, a damaged
// crate, a signed delivery — and it was only ever shown at the size of its
// slot. You could not read a label on a 40px swatch or see the chip in a
// worktop at 64px.
//
// The viewer is `AttachmentViewerModal`, which the app already had and which
// already handled Download and Print: a second photo-only viewer beside it
// would be two things to keep in step. It gained an optional Replace instead.

// An image that can be looked at. Drop-in for a plain <img> wherever the
// picture is a PHOTO rather than a logo or an icon: same className, plus the
// cursor and the viewer. `onReplace` is optional — a read-only photo is still
// worth seeing large.
function Photo({ src, alt, className, title, caption, onReplace, replaceLabel, style }) {
  const [open, setOpen] = useState(false);
  if (!src) return null;
  return (
    <>
      <img src={src} alt={alt || ''} className={className}
        title={title || 'Click to view larger'}
        onClick={e => { e.stopPropagation(); e.preventDefault(); setOpen(true); }}
        style={Object.assign({ cursor: 'zoom-in' }, style || {})} />
      {open && <AttachmentViewerModal name={title || alt || 'Photo'} url={src}
        onClose={() => setOpen(false)} onReplace={onReplace} replaceLabel={replaceLabel} />}
    </>
  );
}

function ImagePicker({ url, onChange, size, shape, title, caption }) {
  const inputRef = useRef(null);
  const [viewing, setViewing] = useState(false);
  const s = size || 40;
  async function onFile(e) {
    const file = e.target.files[0];
    if (!file) return;
    const dataUrl = await readFileAsDataURL(file);
    onChange(dataUrl);
    e.target.value = '';
  }
  return (
    <span className="relative inline-flex items-center justify-center group/img shrink-0" style={{ width: s, height: s }}>
      {url ? (
        // Clicking the picture LOOKS at it; the ⬆ overlay still replaces it on
        // hover. Two different intentions, and the common one is looking.
        <img src={url} alt="" title="Click to view larger" style={{ cursor: 'zoom-in' }}
          onClick={e => { e.stopPropagation(); setViewing(true); }}
          className={`object-cover w-full h-full ${shape === 'circle' ? 'rounded-full' : 'rounded-md'} border border-[var(--leon-line)]`} />
      ) : (
        <span className={`flex items-center justify-center w-full h-full text-[var(--leon-black)]/25 bg-[var(--leon-cream)] border border-dashed border-[var(--leon-line)] ${shape === 'circle' ? 'rounded-full' : 'rounded-md'}`} style={{ fontSize: s * 0.4 }}>
          🖼
        </span>
      )}
      <button
        type="button"
        onClick={() => inputRef.current.click()}
        title={url ? 'Replace image' : 'Add image'}
        className={`no-print absolute ${url ? 'right-0 bottom-0 w-1/2 h-1/2 rounded-tl-md' : 'inset-0'} flex items-center justify-center text-white text-xs bg-black/50 opacity-0 group-hover/img:opacity-100 transition-opacity ${shape === 'circle' ? 'rounded-full' : 'rounded-md'}`}
      >
        ⬆
      </button>
      <input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={onFile} />
      {viewing && <AttachmentViewerModal name={title || 'Photo'} url={url}
        onClose={() => setViewing(false)} onReplace={dataUrl => onChange(dataUrl)} />}
    </span>
  );
}

// A real freehand signature capture — canvas + pointer/touch handlers —
// feeding the exact same clientSignatureUrl string field ImagePicker already
// populates elsewhere (Delivery's Confirm Delivered flow), so no data-model
// change was needed for the Delivery Driver Hub's proof-of-delivery flow,
// just a real capture surface (toDataURL()) instead of a photo of paper.
function SignaturePad({ onChange, width, height }) {
  const canvasRef = useRef(null);
  const drawingRef = useRef(false);
  const [hasDrawn, setHasDrawn] = useState(false);
  const w = width || 500;
  const h = height || 160;

  function pos(e) {
    const rect = canvasRef.current.getBoundingClientRect();
    const scaleX = canvasRef.current.width / rect.width;
    const scaleY = canvasRef.current.height / rect.height;
    const point = e.touches && e.touches.length ? e.touches[0] : e;
    return { x: (point.clientX - rect.left) * scaleX, y: (point.clientY - rect.top) * scaleY };
  }
  function start(e) {
    e.preventDefault();
    drawingRef.current = true;
    const c = canvasRef.current.getContext('2d');
    const { x, y } = pos(e);
    c.beginPath();
    c.moveTo(x, y);
  }
  function move(e) {
    if (!drawingRef.current) return;
    e.preventDefault();
    const c = canvasRef.current.getContext('2d');
    const { x, y } = pos(e);
    c.strokeStyle = '#161311';
    c.lineWidth = 2;
    c.lineCap = 'round';
    c.lineTo(x, y);
    c.stroke();
    if (!hasDrawn) setHasDrawn(true);
  }
  function end() {
    if (!drawingRef.current) return;
    drawingRef.current = false;
    onChange(canvasRef.current.toDataURL('image/png'));
  }
  function clear() {
    const c = canvasRef.current.getContext('2d');
    c.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
    setHasDrawn(false);
    onChange(null);
  }

  return (
    <div>
      <canvas
        ref={canvasRef}
        width={w}
        height={h}
        className="w-full border border-[var(--leon-line)] rounded-lg bg-white"
        style={{ maxWidth: w, touchAction: 'none' }}
        onMouseDown={start} onMouseMove={move} onMouseUp={end} onMouseLeave={end}
        onTouchStart={start} onTouchMove={move} onTouchEnd={end}
      />
      <div className="flex items-center justify-between mt-1 no-print">
        <span className="text-[11px] text-[var(--leon-black)]/40">{hasDrawn ? 'Signed' : 'Sign above'}</span>
        <button type="button" onClick={clear} className="text-xs text-[var(--leon-brown)] font-semibold">Clear</button>
      </div>
    </div>
  );
}

// ---- Attachment viewing ----------------------------------------------------
// Every attachment in the app (uploaded file with a real data: URL, or a
// seed/demo record that only ever had a filename string) opens the SAME
// in-app viewer instead of a bare `target="_blank"` link — images and PDFs
// render inline, with Print/Download in the footer. A name-only attachment
// (no url — most seed data, since there's no real file behind it) still
// opens the viewer and says so plainly, rather than being dead, unclickable
// text, which is what "attachments are currently unaccessible" meant.
// Data URLs (almost everything uploaded through this app) carry their MIME
// type right in the URL, which is more reliable than sniffing the filename —
// a photo thumbnail's "name" is often just a label like "Photo 1" with no
// extension at all.
function isImageFile(name, url) {
  if (url && /^data:image\//i.test(url)) return true;
  if (url && /^data:/i.test(url)) return false;
  return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(name || '');
}
function isPdfFile(name, url) {
  if (url && /^data:application\/pdf/i.test(url)) return true;
  if (url && /^data:/i.test(url)) return false;
  return /\.pdf$/i.test(name || '');
}

// `onReplace(dataUrl, fileName)` is optional and simply passes through to the
// viewer, so an attachment can be swapped from the place you opened it to look
// at it. Without it the viewer is read-only, which is right for anyone who may
// see a file but not change it.
// What a file LOOKS like before you open it. An extension is the only thing
// available for a document, so it picks the glyph; a picture shows itself.
function attachmentGlyph(name) {
  const ext = String(name || '').split('.').pop().toLowerCase();
  if (['pdf'].indexOf(ext) >= 0) return '📕';
  if (['xlsx', 'xls', 'csv', 'numbers'].indexOf(ext) >= 0) return '📊';
  if (['doc', 'docx', 'rtf', 'pages', 'txt'].indexOf(ext) >= 0) return '📝';
  if (['dwg', 'dxf', 'rvt', 'skp'].indexOf(ext) >= 0) return '📐';
  if (['ppt', 'pptx', 'key'].indexOf(ext) >= 0) return '🖼️';
  if (['zip', 'rar', '7z'].indexOf(ext) >= 0) return '🗜️';
  return '📎';
}

// Every attachment in the app reads as a small CARD, not a line of text: a
// 28px thumbnail — the picture itself where it is one — beside the file name.
// AttachmentLink is what FileField renders, so this one change covers all ~110
// places a file appears, which is the whole reason it is done here rather than
// per screen. It stays INLINE and only ~32px tall, so it drops into the list
// rows and table cells it already lived in without pushing anything about.
// `className` remains a full override for anywhere a bare line is genuinely
// wanted; nothing in the app passes one now.
function AttachmentLink({ name, url, className, onReplace, accept }) {
  const [open, setOpen] = useState(false);
  if (!name) return <span className="text-xs text-[var(--leon-black)]/30 italic">No attachment</span>;
  const showsPicture = isImageFile(name, url) && !!url;
  return (
    <>
      <button type="button" onClick={e => { e.stopPropagation(); setOpen(true); }} title={'View ' + name}
        className={className || 'inline-flex items-center gap-1.5 max-w-[240px] rounded-md border border-[var(--leon-line)] bg-white pl-1 pr-2 py-1 text-left align-middle hover:border-[var(--leon-brown)] hover:bg-[var(--leon-cream)]/50 transition'}>
        {!className && (
          <span className="w-7 h-7 rounded shrink-0 overflow-hidden grid place-items-center bg-[var(--leon-cream)] text-[13px] leading-none">
            {showsPicture
              ? <img src={url} alt="" className="w-full h-full object-cover" />
              : <span aria-hidden="true">{isPdfFile(name, url) ? '📕' : attachmentGlyph(name)}</span>}
          </span>
        )}
        <span className={className ? '' : 'text-[11px] font-semibold text-[var(--leon-brown)] truncate'}>
          {className ? (isImageFile(name, url) ? '🖼️ ' : isPdfFile(name, url) ? '📄 ' : '📎 ') : ''}{name}
        </span>
      </button>
      {/* The seed records carry a file NAME with no file behind them, so the
          label has to follow what is actually there rather than the name. */}
      {open && <AttachmentViewerModal name={name} url={url} onClose={() => setOpen(false)}
        onReplace={onReplace} accept={accept}
        replaceLabel={url ? 'Replace this file' : 'Attach the file'} />}
    </>
  );
}

// `onReplace` is optional. Where it is given, Replace sits in the footer UNDER
// the image rather than over it: replacing is a deliberate act, and a control
// laid across the picture you opened to look at is both in the way and too easy
// to hit by accident.
function AttachmentViewerModal({ name, url, onClose, onReplace, replaceLabel, accept }) {
  const replaceRef = useRef(null);
  const isImg = isImageFile(name, url);
  const isPdf = isPdfFile(name, url);
  const body = (
    <>
      {url && isImg && <img src={url} alt={name} className="max-w-full max-h-[65vh] object-contain rounded" />}
      {url && isPdf && <iframe src={url} title={name} className="w-full h-[65vh] border-0 rounded bg-white" />}
      {url && !isImg && !isPdf && (
        <div className="text-center py-10">
          <p className="text-sm text-[var(--leon-black)]/50 mb-2">Preview isn't available for this file type.</p>
          <a href={url} target="_blank" rel="noopener noreferrer" className="text-sm text-[var(--leon-brown)] hover:underline">Open in a new tab</a>
        </div>
      )}
      {!url && (
        <div className="text-center py-10">
          <div className="text-4xl mb-2">{isImg ? '🖼️' : isPdf ? '📄' : '📎'}</div>
          <p className="text-sm text-[var(--leon-black)]/50">No file on record for this item.</p>
        </div>
      )}
    </>
  );
  return (
    <>
      <Modal open onClose={onClose} wide title={name} footer={<>
        {url && <a href={url} download={name} className="no-print text-xs font-semibold text-[var(--leon-brown)] hover:underline mr-auto self-center">Download</a>}
        {url && <PrintButton onClick={() => window.print()} label="Print" />}
        {onReplace && (
          <>
            <Button variant="outline" onClick={() => replaceRef.current.click()}>
              {replaceLabel || (url ? 'Replace' : 'Add a photo')}
            </Button>
            <input ref={replaceRef} type="file" accept={accept === '*' ? undefined : (accept || 'image/*')} className="hidden"
              onChange={async e => {
                const file = e.target.files[0];
                e.target.value = '';
                if (!file) return;
                await onReplace(await readFileAsDataURL(file), file.name);
                onClose();
              }} />
          </>
        )}
        <Button variant="ghost" onClick={onClose}>Close</Button>
      </>}>
        <div className="flex items-center justify-center bg-[var(--leon-cream)] rounded-lg min-h-[320px] p-3">{body}</div>
      </Modal>
      {url && (
        <div className="print-only print-area p-6">
          <h2 className="font-bold text-base mb-3">{name}</h2>
          {isImg && <img src={url} alt={name} className="max-w-full" />}
          {isPdf && <iframe src={url} title={name} className="w-full h-[95vh] border-0" />}
          {!isImg && !isPdf && <p className="text-sm text-gray-500">{name}</p>}
        </div>
      )}
    </>
  );
}

// Small inline photo thumbnail (delivery/punch-list/field-issue photos etc.)
// that opens the same viewer/print modal on click, instead of sitting there
// as an inert thumbnail.
function ClickableImage({ src, alt, className, name }) {
  const [open, setOpen] = useState(false);
  if (!src) return null;
  return (
    <>
      <button type="button" onClick={e => { e.stopPropagation(); setOpen(true); }} title="View photo" className="block shrink-0">
        <img src={src} alt={alt || ''} className={className || 'w-16 h-16 object-cover rounded-md'} />
      </button>
      {open && <AttachmentViewerModal name={name || alt || 'Photo'} url={src} onClose={() => setOpen(false)} />}
    </>
  );
}

// Generic file attachment: upload (stored as a data URL) + click the filename
// to open it in the shared AttachmentViewerModal above.
// `projectId` and `label` are optional and only feed the share — every existing
// call site keeps working untouched.
function FileField({ name, url, onChange, editable, placeholder, projectId, label }) {
  const inputRef = useRef(null);
  const [sharing, setSharing] = useState(false);
  // Any uploaded file is shareable, wherever it lives. ctx comes from the
  // module-level registry (data.jsx) because FileField is used in hundreds of
  // places that never had a ctx to pass.
  const shareCtx = activeShareCtx();
  async function onPick(e) {
    const file = e.target.files[0];
    if (!file) return;
    const dataUrl = await readFileAsDataURL(file);
    onChange(file.name, dataUrl);
    e.target.value = '';
  }
  return (
    <span className="inline-flex items-center gap-1.5">
      {name
        ? <AttachmentLink name={name} url={url} accept="*"
            onReplace={editable ? ((dataUrl, fileName) => onChange(fileName, dataUrl)) : null} />
        : <span className="text-xs text-[var(--leon-black)]/30 italic">{placeholder || 'No attachment'}</span>}
      {editable && (
        <>
          <button type="button" onClick={() => inputRef.current.click()} className="no-print text-[11px] text-[var(--leon-brown)] font-semibold whitespace-nowrap">{name ? 'Replace' : '+ Attach'}</button>
          <input ref={inputRef} type="file" className="hidden" onChange={onPick} />
        </>
      )}
      {name && shareCtx && (
        <>
          <button type="button" onClick={() => setSharing(true)} title="Share this file"
            className="no-print text-[11px] text-[var(--leon-brown)] font-semibold whitespace-nowrap">Share</button>
          <ShareModal open={sharing} onClose={() => setSharing(false)} ctx={shareCtx}
            subject={label ? `${label} — ${name}` : name}
            summary="Attached file"
            subjectKey={`file:${name}`}
            projectId={projectId || null} />
        </>
      )}
    </span>
  );
}

// One pair of buttons that opens or closes every collapsible section on the
// current screen. Lives in the header so it is available on every tab and
// subtab without each one having to wire it up.
// Save as PDF, scoped to one part of the app — and as a DOCUMENT, not a
// picture of the screen.
//
// The first version made everything else invisible and lifted the target to
// the top of the page. That printed exactly what was on screen: cards, badges,
// dropdowns, buttons. What a job needs to hand out is a letterheaded page.
//
// So the region is CLONED into a print-only container, and on the way through
// the clone is turned into document markup: every form control becomes the
// text of its own value, every action button is dropped, and the app's card
// chrome is flattened to rules by the print stylesheet. The live DOM is never
// touched, so React is none the wiser.
//
// cloneNode does NOT copy the live value of an input or the selection of a
// <select> — those are properties, not attributes — which is why the values are
// read off the originals and written into the clone by position.
const PRINT_HOLDER_ID = 'leon-print-holder';
function flattenPrintClone(source, clone) {
  const doc = clone.ownerDocument;
  // Values are read from the ORIGINALS in document order, so each clone
  // element is matched to the control it came from. Do this BEFORE removing
  // anything, or the two lists fall out of step.
  const srcCtl = [...source.querySelectorAll('select, input, textarea')];
  const cloneCtl = [...clone.querySelectorAll('select, input, textarea')];
  cloneCtl.forEach((node, i) => {
    const src = srcCtl[i];
    let text = '';
    if (src) {
      if (src.tagName === 'SELECT') text = src.selectedOptions[0] ? src.selectedOptions[0].textContent : '';
      else if (src.type === 'checkbox' || src.type === 'radio') text = src.checked ? 'Yes' : 'No';
      else if (src.type === 'file') text = '';
      else text = src.value;
    }
    // An empty SEARCH box is screen furniture, not a blank field — printing
    // "—" for it just litters the page. An empty form field still prints its
    // dash, because there "not filled in" is information.
    const isSearch = src && /search|filter/i.test(src.placeholder || '');
    if ((text == null || text === '') && isSearch) { node.remove(); return; }
    const span = doc.createElement('span');
    span.className = 'leon-print-value';
    span.textContent = (text == null || text === '') ? '—' : String(text);
    node.replaceWith(span);
  });
  // A collapsible's header is a <button>, but its text is the SECTION NAME —
  // deleting it with the other buttons left a document of unlabelled tables.
  // It becomes a heading instead.
  clone.querySelectorAll('button[data-section-head]').forEach(b => {
    // The actions living in the header's right-hand slot (Share, PDF) are
    // stripped FIRST — reading the text before that produced headings like
    // "Kitchen Cabinetry(18)Casework PDFSupply & Install Share package".
    b.querySelectorAll('.no-print, button, .chev').forEach(n => n.remove());
    // One idea per fragment. Descending to the leaves split "(18)" and
    // "11/18" apart; stopping at the top ran the right-hand badges together.
    // So it descends only until an element that holds text of its own — that
    // element IS one idea, whatever is nested inside it.
    const parts = [];
    const collect = node => {
      if (node.nodeType === 3) {
        const t = node.textContent.replace(/\s+/g, ' ').trim();
        if (t) parts.push(t);
        return;
      }
      if (node.nodeType !== 1) return;
      const ownText = [...node.childNodes].some(c => c.nodeType === 3 && c.textContent.trim());
      if (ownText || !node.children.length) {
        const t = (node.textContent || '').replace(/\s+/g, ' ').trim();
        if (t) parts.push(t.replace(/([^\s(])\(/g, '$1 ('));
        return;
      }
      [...node.childNodes].forEach(collect);
    };
    [...b.childNodes].forEach(collect);
    const h = doc.createElement('div');
    h.className = 'lp-section-title';
    h.textContent = parts.join(' \u00b7 ');
    b.replaceWith(h);
  });
  // Anything explicitly excluded, plus the controls that only make sense on a
  // screen. A button is an action; on paper it is noise.
  clone.querySelectorAll('.no-print, button, [role="button"], .chev').forEach(n => n.remove());
  // Toolbars emptied by the pass above leave ruled boxes around nothing. An
  // element with no text, no image and no table left in it has no reason to
  // take up space on paper.
  for (let pass = 0; pass < 3; pass++) {
    clone.querySelectorAll('div, span, p').forEach(n => {
      if (n.querySelector('img, table, svg, canvas')) return;
      if ((n.textContent || '').trim()) return;
      n.remove();
    });
  }
  return clone;
}
// The letterhead: LEON on the left, what this document is about on the right.
function buildPrintHeader(o) {
  const h = document.createElement('div');
  h.id = 'leon-print-header';
  const left = document.createElement('div');
  left.className = 'lp-brand';
  const img = document.createElement('img');
  img.src = 'logo/leon-wordmark.svg';
  img.alt = 'LEON';
  const sub = document.createElement('div');
  sub.className = 'lp-brand-sub';
  sub.textContent = 'OPERATIONS HUB';
  left.appendChild(img); left.appendChild(sub);
  const right = document.createElement('div');
  right.className = 'lp-meta';
  // Always present, even when empty — printRegion fills it in afterwards for a
  // section whose title is a React node rather than a string.
  const t = document.createElement('div');
  t.className = 'lp-heading';
  t.textContent = o.heading && o.heading !== 'this section' ? o.heading : '';
  right.appendChild(t);
  (o.lines || []).filter(Boolean).forEach(line => {
    const d = document.createElement('div');
    d.className = 'lp-line';
    d.textContent = line;
    right.appendChild(d);
  });
  const dt = document.createElement('div');
  dt.className = 'lp-line lp-date';
  dt.textContent = 'Printed ' + new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
  right.appendChild(dt);
  h.appendChild(left); h.appendChild(right);
  return h;
}
// ---------------------------------------------------------------------------
// PDF export — a real file, written as text
// ---------------------------------------------------------------------------
// "Save as PDF" used to open the print dialog and leave the rest to the OS.
// This writes the PDF itself and downloads it. Deliberately NOT a screenshot
// library (html2canvas and friends): those rasterise the screen, which is the
// exact thing this is meant to stop being. jsPDF + autoTable put real text and
// real tables on the page, so the result is selectable, searchable and sharp.
//
// It reads the SAME flattened clone the print path builds, so the two can
// never drift: whatever prints is what exports.
const PDF_MARGIN = 40;          // pt
const PDF_HEADER_H = 62;        // pt reserved for the letterhead

// The wordmark is vector art in an SVG; jsPDF cannot place an SVG, so it is
// rasterised once through a canvas at 3x and cached for the session.
let __leonMarkPng = null;
function leonMarkPng() {
  if (__leonMarkPng) return Promise.resolve(__leonMarkPng);
  return new Promise(resolve => {
    const img = new Image();
    img.onload = () => {
      try {
        // Sized to what it is actually drawn at (about 96pt wide) times a
        // retina factor. Rasterising the full-size SVG at 3x put nearly a
        // megabyte of logo into every PDF.
        const natW = img.width || 300, natH = img.height || 60;
        const targetW = 260;
        const c = document.createElement('canvas');
        c.width = targetW;
        c.height = Math.round(targetW * (natH / natW));
        const g = c.getContext('2d');
        g.fillStyle = '#ffffff'; g.fillRect(0, 0, c.width, c.height);
        g.drawImage(img, 0, 0, c.width, c.height);
        __leonMarkPng = { data: c.toDataURL('image/png'), w: c.width, h: c.height };
      } catch (e) { __leonMarkPng = null; }
      resolve(__leonMarkPng);
    };
    img.onerror = () => resolve(null);
    img.src = 'logo/leon-wordmark.svg';
  });
}

// The flattened clone, reduced to an ordered list of blocks. A table is a
// table; a section title is a heading; anything else with text of its own is
// a line. Descending stops at whichever of those comes first, so nothing is
// emitted twice.
function pdfBlocks(root) {
  const out = [];
  const walk = node => {
    if (node.nodeType === 3) {
      const t = node.textContent.replace(/\s+/g, ' ').trim();
      if (t) out.push({ type: 'p', text: t });
      return;
    }
    if (node.nodeType !== 1) return;
    if (node.tagName === 'TABLE') {
      const head = [...node.querySelectorAll('thead tr')].map(tr =>
        [...tr.children].map(c => c.textContent.replace(/\s+/g, ' ').trim()));
      const bodyRows = node.querySelector('tbody') ? [...node.querySelectorAll('tbody tr')] : [...node.querySelectorAll('tr')];
      const body = bodyRows.map(tr => [...tr.children].map(c => c.textContent.replace(/\s+/g, ' ').trim()));
      // A table of nothing is a rule across the page and no information.
      if (body.some(r => r.some(Boolean))) out.push({ type: 'table', head, body });
      return;
    }
    if (node.classList && node.classList.contains('lp-section-title')) {
      const t = node.textContent.replace(/\s+/g, ' ').trim();
      if (t) out.push({ type: 'h', text: t });
      return;
    }
    const ownText = [...node.childNodes].some(c => c.nodeType === 3 && c.textContent.trim());
    if (ownText) {
      const t = node.textContent.replace(/\s+/g, ' ').trim();
      if (t) out.push({ type: 'p', text: t });
      return;
    }
    [...node.childNodes].forEach(walk);
  };
  [...root.childNodes].forEach(walk);
  return out;
}

function safeFileName(s) {
  return String(s || 'LEON Operations Hub')
    .replace(/[\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
}


// ── Submittal packages ─────────────────────────────────────────────────────
// A shop drawing submittal is not a folder of files — it is ONE document, in a
// stated order, with a cover that says what is in it. That is what gets issued,
// what the architect marks up and what comes back. The Hub could produce every
// page of one and had no way to bind them.
//
// This lives in components.jsx because BOTH LEON Doors and LEON Casework issue
// packages and components.jsx loads before either.
//
// The pages come in as SVG elements (a shop drawing sheet) or as tables (a
// schedule). Each is placed on its own PDF page at its own paper size, so an A2
// sheet stays A2 and a Letter schedule stays Letter.

// An SVG sized in millimetres, rasterised at print resolution. Same technique
// the door sheet's own PDF button uses — a drawing has to be rasterised rather
// than reduced to text, which is what flattens rotated dimensions.
function leonSvgToPng(svgEl, wMm, hMm, dpi) {
  return new Promise((resolve, reject) => {
    if (!svgEl) { reject(new Error('no drawing')); return; }
    const px = mm => Math.round((mm / 25.4) * (dpi || 200));
    const clone = svgEl.cloneNode(true);
    clone.setAttribute('width', px(wMm)); clone.setAttribute('height', px(hMm));
    const xml = new XMLSerializer().serializeToString(clone);
    const img = new Image();
    img.onload = () => {
      const c = document.createElement('canvas');
      c.width = px(wMm); c.height = px(hMm);
      const g = c.getContext('2d');
      g.fillStyle = '#ffffff'; g.fillRect(0, 0, c.width, c.height);
      g.drawImage(img, 0, 0, c.width, c.height);
      resolve(c.toDataURL('image/png'));
    };
    img.onerror = () => reject(new Error('the drawing could not be rasterised'));
    img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(xml);
  });
}

// Build the package. `pages` is an ordered list:
//   { kind: 'drawing', svg, wMm, hMm, title }
//   { kind: 'table', title, columns: [], rows: [[]] }
//   { kind: 'notes', title, lines: [] }
// Returns { blobUrl, fileName, pageCount, manifest } — the manifest is what gets
// filed against the scope, because that is the record that has to survive even
// when the file itself is too big to keep in a browser.
async function leonBuildPackage(pages, meta) {
  if (!window.jspdf || !window.jspdf.jsPDF) throw new Error('The PDF library did not load.');
  const m = meta || {};
  const cover = { w: 210, h: 297 };                       // A4 portrait cover
  const pdf = new window.jspdf.jsPDF({ orientation: 'portrait', unit: 'mm',
    format: [cover.w, cover.h], compress: true });

  // ── The cover. What this is, for what job, at what revision, and what is in
  // it — the contents list is the point of a cover sheet.
  const L = 18;
  let y = 34;
  pdf.setFillColor(17, 17, 17); pdf.rect(0, 0, cover.w, 26, 'F');
  pdf.setTextColor(255, 255, 255); pdf.setFontSize(15);
  pdf.text('LEON', L, 14);
  pdf.setFontSize(7); pdf.setTextColor(220, 220, 220);
  pdf.text('OPERATIONS HUB', L, 19.5);
  pdf.setTextColor(30, 30, 30);
  pdf.setFontSize(16); pdf.text(String(m.title || 'Shop Drawing Submittal'), L, y); y += 8;
  pdf.setFontSize(10); pdf.setTextColor(90, 90, 90);
  [['Project', m.project], ['Scope', m.scope], ['Submittal', m.number],
   ['Revision', m.revision], ['Date', m.date], ['Prepared by', m.preparedBy]]
    .filter(r => r[1]).forEach(r => {
      pdf.setTextColor(140, 140, 140); pdf.text(String(r[0]).toUpperCase(), L, y);
      pdf.setTextColor(30, 30, 30); pdf.text(String(r[1]), L + 34, y);
      y += 6;
    });
  y += 6;
  pdf.setTextColor(140, 140, 140); pdf.setFontSize(8); pdf.text('CONTENTS', L, y); y += 5;
  pdf.setFontSize(9.5); pdf.setTextColor(30, 30, 30);
  pages.forEach((p, i) => {
    if (y > cover.h - 24) { pdf.addPage([cover.w, cover.h], 'portrait'); y = 24; }
    pdf.text(`${String(i + 2).padStart(2, '0')}   ${p.title || p.kind}`, L, y);
    y += 5.4;
  });
  if (m.note) {
    y += 6; pdf.setFontSize(8); pdf.setTextColor(120, 120, 120);
    pdf.text(pdf.splitTextToSize(String(m.note), cover.w - L * 2), L, y);
  }

  // ── The pages, each at its own size.
  for (const p of pages) {
    if (p.kind === 'drawing' && p.svg) {
      const w = p.wMm || 420, h = p.hMm || 297;
      pdf.addPage([w, h], w >= h ? 'landscape' : 'portrait');
      // Compression matters: an A2 sheet embedded raw is tens of megabytes.
      const png = await leonSvgToPng(p.svg, w, h, p.dpi || 180);
      pdf.addImage(png, 'PNG', 0, 0, w, h, undefined, 'MEDIUM');
    } else if (p.kind === 'table') {
      pdf.addPage([297, 210], 'landscape');
      pdf.setFontSize(12); pdf.setTextColor(30, 30, 30);
      pdf.text(String(p.title || 'Schedule'), 14, 16);
      if (pdf.autoTable) {
        pdf.autoTable({
          head: [p.columns || []], body: p.rows || [], startY: 22,
          styles: { fontSize: 7.5, cellPadding: 1.6 },
          headStyles: { fillColor: [30, 30, 30], textColor: 255, fontSize: 7 },
          margin: { left: 14, right: 14 },
        });
      }
    } else if (p.kind === 'notes') {
      pdf.addPage([210, 297], 'portrait');
      pdf.setFontSize(12); pdf.setTextColor(30, 30, 30);
      pdf.text(String(p.title || 'Notes'), L, 22);
      pdf.setFontSize(9); pdf.setTextColor(60, 60, 60);
      let ny = 32;
      (p.lines || []).forEach(line => {
        const wrapped = pdf.splitTextToSize(String(line), 210 - L * 2);
        wrapped.forEach(w => {
          if (ny > 280) { pdf.addPage([210, 297], 'portrait'); ny = 22; }
          pdf.text(w, L, ny); ny += 5;
        });
        ny += 2;
      });
    }
  }

  const fileName = `${(m.project || 'Project').replace(/[^\w\- ]+/g, '')} — ${(m.title || 'Submittal')}${m.revision ? ` ${m.revision}` : ''}.pdf`;
  const blob = pdf.output('blob');
  return {
    blob, fileName,
    sizeMb: +(blob.size / 1048576).toFixed(2),
    pageCount: pdf.getNumberOfPages(),
    manifest: pages.map((p, i) => ({ page: i + 2, kind: p.kind, title: p.title || p.kind })),
  };
}

// A browser cannot write a file to disk on its own, and the preview pane
// sandboxes downloads — so the package is handed over the same honest way the
// rest of the app hands over a file, and the caller says which happened.
function leonSavePackage(built) {
  try {
    const url = URL.createObjectURL(built.blob);
    const a = document.createElement('a');
    a.href = url; a.download = built.fileName;
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 4000);
    return true;
  } catch (e) { return false; }
}

async function exportPdf(el, opts) {
  if (!el) return;
  if (!window.jspdf || !window.jspdf.jsPDF) { alert('The PDF library did not load. Check your connection and reload.'); return; }
  const o = typeof opts === 'string' ? { title: opts, heading: opts } : (opts || {});
  // Collapsed sections have nothing in them to export.
  window.dispatchEvent(new CustomEvent('leon-sections-expand-all'));
  await new Promise(r => setTimeout(r, 200));

  const flat = flattenPrintClone(el, el.cloneNode(true));
  let heading = o.heading;
  if (!heading || heading === 'this section') {
    const first = flat.querySelector('.lp-section-title');
    if (first) heading = first.textContent.split(' · ')[0].trim();
  }
  const blocks = pdfBlocks(flat);
  const mark = await leonMarkPng();

  const { jsPDF } = window.jspdf;
  const doc = new jsPDF({ unit: 'pt', format: 'a4' });
  const pw = doc.internal.pageSize.getWidth();
  const ph = doc.internal.pageSize.getHeight();
  const right = pw - PDF_MARGIN;

  function drawHeader() {
    let ty = PDF_MARGIN + 4;
    if (mark) {
      const w = 96, h = w * (mark.h / mark.w);
      doc.addImage(mark.data, 'PNG', PDF_MARGIN, ty - 2, w, h);
      doc.setFontSize(4.6); doc.setTextColor(107, 74, 52);
      doc.text('OPERATIONS HUB', PDF_MARGIN + 1, ty + h + 7, { charSpace: 2.6 });
    } else {
      doc.setFontSize(20); doc.setTextColor(22, 19, 17);
      doc.text('LEON', PDF_MARGIN, ty + 14, { charSpace: 6 });
    }
    let my = ty + 6;
    if (heading) {
      doc.setFont(undefined, 'bold'); doc.setFontSize(12); doc.setTextColor(22, 19, 17);
      doc.text(String(heading), right, my, { align: 'right' });
      my += 13;
    }
    doc.setFont(undefined, 'normal'); doc.setFontSize(7.5); doc.setTextColor(88, 80, 74);
    (o.lines || []).filter(Boolean).forEach(line => {
      doc.text(String(line), right, my, { align: 'right' }); my += 9.5;
    });
    doc.setTextColor(138, 130, 123);
    doc.text('Printed ' + new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }), right, my, { align: 'right' });
    doc.setDrawColor(22, 19, 17); doc.setLineWidth(1);
    const ry = PDF_MARGIN + PDF_HEADER_H - 10;
    doc.line(PDF_MARGIN, ry, right, ry);
    // Footer
    doc.setDrawColor(216, 208, 198); doc.setLineWidth(0.5);
    doc.line(PDF_MARGIN, ph - PDF_MARGIN - 14, right, ph - PDF_MARGIN - 14);
    doc.setFontSize(6.5); doc.setTextColor(138, 130, 123);
    const foot = (typeof COMPANY_PRINT_ADDRESS !== 'undefined' ? COMPANY_PRINT_ADDRESS : 'LEON Integra');
    doc.text(foot, pw / 2, ph - PDF_MARGIN - 4, { align: 'center' });
    doc.setTextColor(22, 19, 17);
  }

  const top = PDF_MARGIN + PDF_HEADER_H;
  const bottom = ph - PDF_MARGIN - 22;
  let y = top;
  drawHeader();
  function room(need) {
    if (y + need <= bottom) return;
    doc.addPage(); drawHeader(); y = top;
  }

  blocks.forEach(b => {
    if (b.type === 'h') {
      room(30);
      y += 10;
      doc.setFont(undefined, 'bold'); doc.setFontSize(10.5); doc.setTextColor(22, 19, 17);
      const lines = doc.splitTextToSize(b.text, right - PDF_MARGIN);
      doc.text(lines, PDF_MARGIN, y);
      y += lines.length * 12 + 3;
      doc.setDrawColor(22, 19, 17); doc.setLineWidth(0.8);
      doc.line(PDF_MARGIN, y, right, y);
      y += 9;
      return;
    }
    if (b.type === 'p') {
      doc.setFont(undefined, 'normal'); doc.setFontSize(8.5); doc.setTextColor(60, 54, 49);
      const lines = doc.splitTextToSize(b.text, right - PDF_MARGIN);
      room(lines.length * 10 + 4);
      doc.text(lines, PDF_MARGIN, y);
      y += lines.length * 10 + 4;
      return;
    }
    // table
    doc.autoTable({
      head: b.head.length ? b.head : undefined,
      body: b.body,
      startY: y,
      margin: { left: PDF_MARGIN, right: PDF_MARGIN, top, bottom: ph - bottom },
      theme: 'plain',
      styles: { fontSize: 7.5, cellPadding: 3, textColor: [22, 19, 17], lineColor: [229, 222, 212], lineWidth: { bottom: 0.4 } },
      headStyles: { fontSize: 6.5, fontStyle: 'bold', textColor: [88, 80, 74], lineWidth: { bottom: 0.9 }, lineColor: [22, 19, 17] },
      didDrawPage: () => drawHeader(),
    });
    y = doc.lastAutoTable.finalY + 12;
  });

  doc.save(safeFileName(o.title || heading) + '.pdf');
}

function printRegion(el, opts) {
  if (!el) return;
  const o = typeof opts === 'string' ? { title: opts, heading: opts } : (opts || {});
  const prevTitle = document.title;
  if (o.title) document.title = o.title;
  // Sections collapsed on screen have no DOM to clone, so they are opened
  // first — a PDF of a page with everything shut is a PDF of nothing.
  window.dispatchEvent(new CustomEvent('leon-sections-expand-all'));

  function cleanup() {
    const holder = document.getElementById(PRINT_HOLDER_ID);
    if (holder) holder.remove();
    document.body.classList.remove('print-scope');
    document.title = prevTitle;
    window.removeEventListener('afterprint', cleanup);
  }

  setTimeout(() => {
    // A running header, done the only way that is reliable across engines:
    // the whole document is one table, the letterhead is its <thead> and the
    // company line its <tfoot>. `display: table-header-group` repeats them on
    // every page AND reserves the space, which `position: fixed` does not —
    // that is what made the header sit on top of the content.
    const holder = document.createElement('div');
    holder.id = PRINT_HOLDER_ID;
    const table = document.createElement('table');
    table.className = 'lp-page';
    const thead = document.createElement('thead');
    const htr = document.createElement('tr');
    const htd = document.createElement('td');
    htd.appendChild(buildPrintHeader(o));
    htr.appendChild(htd); thead.appendChild(htr);
    const tfoot = document.createElement('tfoot');
    const ftr = document.createElement('tr');
    const ftd = document.createElement('td');
    const foot = document.createElement('div');
    foot.id = 'leon-print-footer';
    foot.textContent = (typeof COMPANY_PRINT_ADDRESS !== 'undefined' ? COMPANY_PRINT_ADDRESS : 'LEON Integra');
    ftd.appendChild(foot); ftr.appendChild(ftd); tfoot.appendChild(ftr);
    const tbody = document.createElement('tbody');
    const btr = document.createElement('tr');
    const btd = document.createElement('td');
    const body = document.createElement('div');
    body.className = 'lp-body';
    const flat = flattenPrintClone(el, el.cloneNode(true));
    body.appendChild(flat);
    // A Collapsible whose title is a React node cannot hand PrintButton a
    // heading, and "this section" is no name for a document. The flattened
    // clone carries the section's real title, so it is read back off that.
    const slot = htd.querySelector('.lp-heading');
    if (slot && !slot.textContent) {
      const first = flat.querySelector('.lp-section-title');
      if (first) {
        slot.textContent = first.textContent.split(' \u00b7 ')[0].trim();
        // The document title is the PDF's default filename, so it follows too.
        if (slot.textContent) document.title = slot.textContent;
      }
    }
    btd.appendChild(body); btr.appendChild(btd); tbody.appendChild(btr);
    // tfoot before tbody is the historic requirement and still harmless.
    table.appendChild(thead); table.appendChild(tfoot); table.appendChild(tbody);
    holder.appendChild(table);
    document.body.appendChild(holder);
    document.body.classList.add('print-scope');
    window.addEventListener('afterprint', cleanup);
    window.print();
    setTimeout(cleanup, 1500);
  }, 200);
}

// The per-screen toolbar: open/close every section, print it, save it as a
// PDF. It sits UNDER the subtab bar rather than above it, because it acts on
// the subtab you are actually looking at — above the tabs it read as belonging
// to the whole page, which was the wrong promise.
// `hub-tools-child` is what tells the page-level toolbar to stand down: see
// the :has() rule in styles.css. One toolbar on screen, always the specific one.
function HubTools({ title, heading, lines, className }) {
  return (
    <div className={`hub-tools-child no-print flex items-center justify-end gap-1 mb-3 -mt-1 ${className || ''}`}>
      <ExpandCollapseAll />
      <span className="w-px h-5 bg-[var(--leon-line)] mx-1" />
      <DocActions title={title} heading={heading} lines={lines} />
    </div>
  );
}

function ExpandCollapseAll({ className }) {
  const fire = type => window.dispatchEvent(new CustomEvent(type));
  return (
    <span className={`no-print inline-flex items-center gap-1 ${className || ''}`}>
      <IconAction icon="⤢" title="Open every section on this screen" onClick={() => fire('leon-sections-expand-all')} />
      <IconAction icon="⤡" title="Close every section on this screen" onClick={() => fire('leon-sections-collapse-all')} />
    </span>
  );
}

function ConfirmBar({ text, confirmLabel, onConfirm, onCancel, tone }) {
  return (
    <div className={`flex items-center justify-between gap-3 rounded-md px-3 py-2 text-sm ${tone === 'danger' ? 'bg-[#fbe7e7]' : 'bg-[var(--leon-cream)]'}`}>
      <span>{text}</span>
      <div className="flex gap-2 shrink-0">
        <Button size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
        <Button size="sm" variant={tone === 'danger' ? 'danger' : 'primary'} onClick={onConfirm}>{confirmLabel}</Button>
      </div>
    </div>
  );
}

// ── AI hand-off ────────────────────────────────────────────────────────────
// One chooser for every ✨ control in the app: pick the assistant, see the exact
// brief, then hand it over. It is deliberately NOT dressed up as the Hub having
// AI — the Hub still has none, and the panel says so. What it does is write the
// request properly and open the app that can answer it.
function AiHandoffModal({ open, onClose, title, brief, onSent }) {
  const [choice, setChoice] = useState(AI_PROVIDERS[0].key);
  const [sent, setSent] = useState(null);
  const [showBrief, setShowBrief] = useState(false);
  useEffect(() => {
    if (!open) return;
    setChoice(rememberedAiProvider() || AI_PROVIDERS[0].key);
    setSent(null); setShowBrief(false);
  }, [open]);
  const provider = aiProviderByKey(choice);
  // The send button stays after a hand-off rather than collapsing to Close: the
  // common next move when nothing opened is to try the OTHER assistant, and a
  // button that disappears makes you shut the dialog and start over.
  function send() {
    const result = openInAi(brief, choice);
    setSent(result);
    if (onSent) onSent(result);
  }
  return (
    <Modal open={open} onClose={onClose} wide title={title || 'Ask an AI assistant'}
      footer={<><Button variant="ghost" onClick={onClose}>{sent ? 'Close' : 'Cancel'}</Button>
               <div className="flex-1" />
               <Button onClick={send}>{sent && sent.provider.key === choice ? 'Open again in ' : 'Open in '}{provider.name}</Button></>}>
      <div className="space-y-3">
        <div className="grid sm:grid-cols-2 gap-2">
          {AI_PROVIDERS.map(p => (
            <button key={p.key} type="button" onClick={() => setChoice(p.key)}
              className={`text-left border rounded-lg px-3 py-2.5 transition ${choice === p.key
                ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]'
                : 'border-[var(--leon-line)] hover:border-[var(--leon-brown-light)]'}`}>
              <span className="text-sm font-bold">{p.name}</span>
              <span className="text-[11px] text-[var(--leon-black)]/45 ml-1.5">{p.vendor}</span>
              <span className="block text-[11px] text-[var(--leon-black)]/55 leading-snug mt-0.5">{p.note}</span>
            </button>
          ))}
        </div>

        {/* What actually happens, in the words of what the browser can do. */}
        <p className="text-[11px] text-[var(--leon-black)]/55 leading-snug">
          The brief is copied to your clipboard, then {provider.name} is opened with it already written in
          &mdash; the desktop app if it is installed on this machine, otherwise the website.
          <strong> The Hub has no AI of its own</strong>, and no file is uploaded: a link cannot carry a
          document, so attach anything it needs on the other side and bring the answer back here.
        </p>

        <div>
          <button type="button" onClick={() => setShowBrief(v => !v)}
            className="text-xs font-semibold text-[var(--leon-brown)] hover:underline">
            {showBrief ? 'Hide the brief' : 'See the brief'} ({brief.length.toLocaleString()} characters)
          </button>
          {showBrief && (
            <pre className="mt-2 max-h-56 overflow-y-auto whitespace-pre-wrap text-[11px] leading-snug bg-[var(--leon-cream)]/60 border border-[var(--leon-line)] rounded-lg p-3">{brief}</pre>
          )}
        </div>

        {sent && (
          <div className="rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)]/60 p-3 text-xs leading-snug">
            <p className="font-semibold mb-1">Handed to {sent.provider.name}.</p>
            <p className="text-[var(--leon-black)]/60">
              {sent.copied
                ? 'The full brief is on your clipboard — paste it if the window opened empty.'
                : 'This browser would not let the page write to the clipboard, so copy the brief above if the window opened empty.'}
              {sent.truncated && ' It was too long for a link, so what opened is the first part; the whole brief is on the clipboard.'}
            </p>
            <p className="text-[var(--leon-black)]/60 mt-1">
              Nothing opened? {sent.provider.name} may not be installed and the popup may have been blocked
              &mdash; open it yourself and paste.
            </p>
          </div>
        )}
      </div>
    </Modal>
  );
}
// The ✨ control itself. Any site that wants one passes a label and a brief.
function AiHandoffButton({ label, brief, title, size, variant, className, disabled }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button size={size || 'sm'} variant={variant || 'ghost'} className={className} disabled={disabled}
        onClick={() => setOpen(true)}>&#10024; {label}</Button>
      <AiHandoffModal open={open} onClose={() => setOpen(false)} title={title || label} brief={brief} />
    </>
  );
}

// The project's own tab bar. The tabs are in the order a job actually moves
// through, and a light chevron between them says so — it carries the sequence
// without spending two lines on phase headings, which is what the grouped
// version cost. The chevron is decorative and aria-hidden; the order is the
// real information.
//
// The type scale across the three levels is deliberate and was backwards:
// header nav 14px, this bar 13px, the subtab bars inside a screen 12px. Reading
// down the page should feel like going down a level.
function FlowTabs({ tabs, active, onChange }) {
  return (
    <div className="no-print flex flex-wrap items-center gap-y-1 border-b border-[var(--leon-line)] pb-1.5">
      {(tabs || []).map((t, i) => (
        <React.Fragment key={t.key}>
          {i > 0 && (
            <span aria-hidden="true" className="px-1 text-[11px] text-[var(--leon-black)]/20 select-none">&rsaquo;</span>
          )}
          <button onClick={() => onChange(t.key)} title={t.label}
            className={`shrink-0 whitespace-nowrap px-2 py-1 rounded-md text-[13px] font-semibold transition ${
              active === t.key
                ? 'bg-[var(--leon-black)] text-white'
                : 'text-[var(--leon-black)]/55 hover:bg-[var(--leon-cream)] hover:text-[var(--leon-black)]'}`}>
            {t.icon && <span aria-hidden="true" className={`mr-1 ${active === t.key ? '' : 'opacity-75'}`}>{t.icon}</span>}
            {t.short || t.label}
          </button>
        </React.Fragment>
      ))}
    </div>
  );
}

// ── Locked until you ask to edit ───────────────────────────────────────────
// A screen you opened to LOOK something up should not let you change it by
// tabbing through it. This wraps a block of fields and keeps them inert until
// Edit is pressed.
//
// How, and why this way: one wrapper per section rather than a change to every
// field. On lock it walks its own subtree and makes each focusable element
// unreachable — pointer events off through a class, and tabIndex -1 so the
// keyboard cannot land on it either. It does NOT use the `inert` attribute,
// which would also strip the section out of the accessibility tree: a locked
// section still has to be readable, it just must not be changeable.
//
// **Changes still save as they are made.** That is why the button says Done and
// not Save — this app has never had an unsaved-draft state and introducing one
// would mean a half-finished edit could be lost by navigating away. Done closes
// the lock; it does not commit anything, because there is nothing pending.
function EditLock({ title, hint, canEdit, children, className, defaultOpen }) {
  const [editing, setEditing] = useState(!!defaultOpen);
  const ref = useRef(null);
  const locked = !canEdit || !editing;
  useEffect(() => {
    const root = ref.current;
    if (!root) return;
    const focusables = root.querySelectorAll('input, select, textarea, button, [contenteditable="true"], a[href]');
    focusables.forEach(el => {
      if (locked) {
        // Remember what it was, so unlocking restores rather than invents.
        if (el.dataset.lockPrevTab === undefined) el.dataset.lockPrevTab = el.getAttribute('tabindex') || '';
        el.setAttribute('tabindex', '-1');
        // pointer-events only stops a MOUSE. A control that is genuinely
        // disabled cannot be activated by a click, by the keyboard, or by
        // anything else — which is what "locked" has to mean if it is worth
        // having. Only controls we disabled ourselves are re-enabled, so a
        // field that was already disabled for its own reason stays that way.
        if ('disabled' in el && !el.disabled) {
          el.disabled = true;
          el.dataset.lockDisabled = '1';
        }
      } else {
        if (el.dataset.lockPrevTab !== undefined) {
          if (el.dataset.lockPrevTab === '') el.removeAttribute('tabindex');
          else el.setAttribute('tabindex', el.dataset.lockPrevTab);
          delete el.dataset.lockPrevTab;
        }
        if (el.dataset.lockDisabled) { el.disabled = false; delete el.dataset.lockDisabled; }
      }
    });
  });
  if (!canEdit) return <div className={className}>{children}</div>;
  return (
    <div className={className}>
      {/* Quiet while locked — the section is being read and the control should
          not compete with it — and unmistakable while live. */}
      <div className="flex items-center gap-2 mb-1.5 no-print">
        {title && <span className="text-[10px] uppercase tracking-wide font-bold text-[var(--leon-black)]/40">{title}</span>}
        {editing && <span className="text-[10px] uppercase tracking-wide font-bold text-[var(--leon-brown)]">Editing</span>}
        <div className="flex-1" />
        <button type="button" onClick={() => setEditing(v => !v)}
          className={`text-[11px] font-semibold rounded px-2 py-0.5 transition ${editing
            ? 'bg-[var(--leon-brown)] text-white'
            : 'text-[var(--leon-black)]/40 hover:text-[var(--leon-brown)] hover:bg-[var(--leon-cream)]'}`}>
          {editing ? '\u2713 Done' : '\u270E Edit'}
        </button>
      </div>
      <div ref={ref} aria-disabled={locked || undefined}
        className={locked ? 'leon-locked' : ''}>
        {children}
      </div>
    </div>
  );
}

// ── The workspace shape every LEON software shares ──────────────────────────
// Modelled on the tools the team already uses all day, because the request was
// explicitly that these should navigate the way those do: Take-off against
// Bluebeam Revu, Countertop against Moraware CounterGo, Casework against 2020
// Design, Studio against Photoshop, LEON PDF against Acrobat.
//
// Those five programs disagree about almost everything except their SHAPE, and
// the shape is the part worth copying:
//   • a TOOL RAIL down the left — Photoshop's toolbar, Acrobat's All tools list,
//     Bluebeam's tool palette. Vertical, icon-first, collapsible to icons only.
//   • an OPTIONS BAR across the top of the work area that changes with whatever
//     is selected. This is Photoshop's Options Bar exactly, and it is the piece
//     LEON was missing entirely.
//   • the WORK AREA itself.
//   • a STATUS BAR along the bottom, which is where Photoshop and Bluebeam both
//     put the quiet running facts (zoom, counts, what is selected).
//
// What the softwares had instead was a horizontal row of tabs — a web pattern,
// not a drawing-tool one. It also broke down at 30 sections: Casework's bar
// wrapped onto three rows and pushed the actual work off the screen.
//
// The rail is deliberately NOT a re-skin of the tab bar: a vertical list can
// carry thirty entries without wrapping, can be grouped with headings, and can
// collapse to a 52px icon strip when the work needs the width — which is what
// every one of those five programs lets you do.
const SOFTWARE_RAIL_KEY = 'leon-software-rail-v1';
function softwareRailCollapsed(swKey) {
  try { return JSON.parse(localStorage.getItem(SOFTWARE_RAIL_KEY) || '{}')[swKey] === true; }
  catch (e) { return false; }
}
function setSoftwareRailCollapsed(swKey, v) {
  try {
    const all = JSON.parse(localStorage.getItem(SOFTWARE_RAIL_KEY) || '{}');
    all[swKey] = !!v;
    localStorage.setItem(SOFTWARE_RAIL_KEY, JSON.stringify(all));
  } catch (e) { /* a preference is not worth an error */ }
}

// A tool link can carry WHERE to open, not only WHICH tool. `?software=casework`
// already booted the right module; `&project=<id>&section=runs` opens it on the
// job and the screen you meant, which is what makes a link to a tool worth
// sending to somebody. Read once, on mount — after that the tool's own state
// owns it, so navigating away from the linked screen is not fought.
// The same question can be asked from INSIDE the app — "open LEON Sign on this
// envelope" is the same intent as `?envelope=<id>` in a link, and it would be a
// second convention if it travelled a different way. So a navigation stages its
// parameters here and `swBootParam` reads the URL first, then the staged set.
// Consumed on read, because a boot parameter is a one-shot instruction: leaving
// it set would drag the tool back to the linked screen on the next mount.
let __swBootPending = {};
function swBootSet(name, value) {
  if (value === null || value === undefined || value === '') delete __swBootPending[name];
  else __swBootPending[name] = String(value);
}
function swBootParam(name) {
  try {
    const fromUrl = new URLSearchParams(window.location.search).get(name);
    if (fromUrl) return fromUrl;
  } catch (e) { /* fall through to the staged set */ }
  if (Object.prototype.hasOwnProperty.call(__swBootPending, name)) {
    const v = __swBootPending[name];
    delete __swBootPending[name];
    return v;
  }
  return null;
}

function SoftwareRail({ swKey, sections, active, onSelect, options, status, children, accent }) {
  const [collapsed, setCollapsed] = useState(() => softwareRailCollapsed(swKey));
  const [focus, setFocus] = useState(false);
  const edge = accent || 'var(--leon-brown)';
  const current = (sections || []).find(s => s.key === active) || null;
  // A `group` on a section turns the rail into headed groups, the way Acrobat
  // groups All tools. Sections without one sit in a single unnamed run, so a
  // module that has not been grouped yet still renders correctly.
  const groups = [];
  (sections || []).forEach(s => {
    const g = s.group || '';
    let bucket = groups.find(x => x.name === g);
    if (!bucket) { bucket = { name: g, items: [] }; groups.push(bucket); }
    bucket.items.push(s);
  });

  function toggle() {
    setCollapsed(c => { setSoftwareRailCollapsed(swKey, !c); return !c; });
  }

  // FOCUS MODE. Collapsing to icons reclaims 138px; a drawing wants the whole
  // screen. This hides the rail and the options bar entirely and takes the work
  // area full-screen, which is what every drafting program gives you and what a
  // shop drawing on an A2 sheet actually needs. Escape comes back.
  useEffect(() => {
    if (!focus) return undefined;
    const onKey = e => { if (e.key === 'Escape') setFocus(false); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [focus]);

  if (focus) {
    return (
      <div className="fixed inset-0 z-50 bg-white flex flex-col">
        <div className="no-print shrink-0 flex items-center gap-2 px-3 h-9 border-b border-[var(--leon-line)] bg-[var(--leon-cream)]/60">
          <span className="text-[11px] font-bold uppercase tracking-[0.14em] text-[var(--leon-black)]/55">
            {current ? `${current.icon || ''} ${current.label}` : 'Full screen'}
          </span>
          {status && <span className="text-[11px] text-[var(--leon-black)]/45">{status}</span>}
          <button onClick={() => setFocus(false)}
            className="ml-auto text-[11px] font-semibold text-[var(--leon-brown)]">
            ⤡ Exit full screen <span className="text-[var(--leon-black)]/35">(Esc)</span>
          </button>
        </div>
        <div className="flex-1 overflow-auto p-3">{children}</div>
      </div>
    );
  }

  return (
    <div className="flex gap-0 items-stretch rounded-lg border border-[var(--leon-line)] bg-white overflow-hidden">
      {/* THE TOOL RAIL */}
      <nav aria-label="Tools" className={`no-print shrink-0 border-r border-[var(--leon-line)] bg-[var(--leon-cream)]/50 flex flex-col ${collapsed ? 'w-[52px]' : 'w-[190px]'}`}>
        <button onClick={toggle} title={collapsed ? 'Show tool names' : 'Collapse to icons'}
          className="h-8 shrink-0 flex items-center justify-center text-[var(--leon-black)]/35 hover:text-[var(--leon-brown)] border-b border-[var(--leon-line)]">
          <span aria-hidden="true" className="text-xs">{collapsed ? '»' : '«'}</span>
        </button>
        <div className="flex-1 overflow-y-auto py-1">
          {groups.map((g, gi) => (
            <div key={g.name || gi} className={gi ? 'mt-2' : ''}>
              {!collapsed && g.name && (
                <p className="px-3 pt-2 pb-1 text-[9px] font-bold uppercase tracking-[0.16em] text-[var(--leon-black)]/35">{g.name}</p>
              )}
              {g.items.map(s => {
                const on = s.key === active;
                return (
                  <button key={s.key} onClick={() => onSelect(s.key)} title={s.label}
                    className={`w-full flex items-center gap-2 text-left px-3 py-1.5 text-[13px] border-l-[3px] transition-colors
                      ${on ? 'border-[color:var(--rail-edge)] bg-white font-semibold text-[var(--leon-black)]'
                           : 'border-transparent text-[var(--leon-black)]/60 hover:bg-white/70 hover:text-[var(--leon-black)]'}
                      ${collapsed ? 'justify-center px-0' : ''}`}
                    style={{ '--rail-edge': edge }}>
                    <span aria-hidden="true" className="text-[15px] leading-none shrink-0">{s.icon}</span>
                    {!collapsed && <span className="truncate">{s.label}</span>}
                  </button>
                );
              })}
            </div>
          ))}
        </div>
      </nav>

      <div className="min-w-0 flex-1 flex flex-col">
        {/* THE OPTIONS BAR — Photoshop's, in name and in behaviour: it belongs
            to whatever is selected in the rail, and it changes when that does. */}
        <div className="no-print shrink-0 flex items-center gap-3 px-3 py-1.5 border-b border-[var(--leon-line)] bg-[var(--leon-cream)]/40 min-h-[38px] flex-wrap">
          {current && (
            <span className="flex items-baseline gap-1.5 shrink-0">
              <span aria-hidden="true">{current.icon}</span>
              <span className="text-[13px] font-bold">{current.label}</span>
            </span>
          )}
          {current && current.hint && (
            <span className="text-[11px] text-[var(--leon-black)]/45 truncate">{current.hint}</span>
          )}
          {options && <span className="ml-auto flex items-center gap-2 flex-wrap">{options}</span>}
          {/* Hide the panels and give the drawing the screen. */}
          <button onClick={() => setFocus(true)} title="Hide the panels and go full screen (Esc to come back)"
            className={`${options ? '' : 'ml-auto'} text-[11px] font-semibold text-[var(--leon-black)]/45 hover:text-[var(--leon-brown)] whitespace-nowrap`}>
            ⤢ Full screen
          </button>
        </div>

        <div className="min-w-0 flex-1 p-3">{children}</div>

        {/* THE STATUS BAR — the quiet running facts, where Photoshop and
            Bluebeam both keep them. Rendered only when a module supplies one. */}
        {status && (
          <div className="no-print shrink-0 flex items-center gap-3 px-3 py-1 border-t border-[var(--leon-line)] bg-[var(--leon-cream)]/40 text-[11px] text-[var(--leon-black)]/55 flex-wrap">
            {status}
          </div>
        )}
      </div>
    </div>
  );
}

// ── The Office ribbon ───────────────────────────────────────────────────────
// Word, Excel and PowerPoint are NOT driven by a left tool rail — they are
// driven by a ribbon, and giving LEON Word a Photoshop-style rail would make it
// LESS like the program it is meant to feel like, not more. So the softwares
// split two ways and deliberately so:
//   • the drawing tools (Take-off, Casework, Countertop, Studio, PDF) get
//     `SoftwareRail` — Photoshop / Acrobat / Bluebeam shape;
//   • the Office tools get this — Word / Excel / PowerPoint shape.
//
// The ribbon's anatomy, copied because it is what makes it legible at forty
// controls: a TAB STRIP (Home, Insert, Data…), and under it the active tab's
// commands arranged in GROUPS, each group labelled underneath and separated by
// a hairline. LEON Sheets had thirty-five controls in two undifferentiated
// rows; the grouping is the entire difference between that and a ribbon.
//
// Office lets you collapse the ribbon to reclaim vertical space, and that
// matters more here than in Office because the grid underneath is the work.
const OFFICE_RIBBON_KEY = 'leon-office-ribbon-v1';
function officeRibbonState(appKey) {
  try { return JSON.parse(localStorage.getItem(OFFICE_RIBBON_KEY) || '{}')[appKey] || {}; }
  catch (e) { return {}; }
}
function setOfficeRibbonState(appKey, patch) {
  try {
    const all = JSON.parse(localStorage.getItem(OFFICE_RIBBON_KEY) || '{}');
    all[appKey] = Object.assign({}, all[appKey], patch);
    localStorage.setItem(OFFICE_RIBBON_KEY, JSON.stringify(all));
  } catch (e) { /* a preference is not worth an error */ }
}

// `tabs` is [{ key, label, groups: [{ label, items }] }]. `items` is JSX — the
// existing controls, moved rather than rewritten, so adopting the ribbon does
// not put a single button at risk.
function OfficeRibbon({ appKey, tabs, right }) {
  const saved = officeRibbonState(appKey);
  const [active, setActive] = useState(saved.tab || (tabs[0] || {}).key);
  const [collapsed, setCollapsed] = useState(!!saved.collapsed);
  const tab = tabs.find(t => t.key === active) || tabs[0];
  if (!tab) return null;

  function pick(k) {
    // Clicking the tab you are already on collapses the ribbon, which is what
    // Office does and what people reach for without being told.
    if (k === active && !collapsed) { setCollapsed(true); setOfficeRibbonState(appKey, { collapsed: true }); return; }
    setActive(k); setCollapsed(false);
    setOfficeRibbonState(appKey, { tab: k, collapsed: false });
  }

  return (
    <div className="no-print border border-[var(--leon-line)] rounded-lg bg-white overflow-hidden">
      <div className="flex items-center gap-0 border-b border-[var(--leon-line)] bg-[var(--leon-cream)]/50 px-1">
        {tabs.map(t => (
          <button key={t.key} onClick={() => pick(t.key)}
            className={`px-3 py-1.5 text-[12px] font-semibold border-b-2 -mb-px whitespace-nowrap
              ${t.key === active && !collapsed
                ? 'border-[var(--leon-brown)] text-[var(--leon-brown)] bg-white'
                : 'border-transparent text-[var(--leon-black)]/55 hover:text-[var(--leon-black)]'}`}>
            {t.label}
          </button>
        ))}
        <span className="ml-auto flex items-center gap-1.5 pr-1">
          {right}
          <button onClick={() => { const v = !collapsed; setCollapsed(v); setOfficeRibbonState(appKey, { collapsed: v }); }}
            title={collapsed ? 'Show the ribbon' : 'Collapse the ribbon'}
            className="px-1.5 py-0.5 text-[11px] text-[var(--leon-black)]/40 hover:text-[var(--leon-brown)]">
            {collapsed ? '▾' : '▴'}
          </button>
        </span>
      </div>

      {!collapsed && (
        <div className="flex items-stretch gap-0 px-1 py-1.5 overflow-x-auto">
          {(tab.groups || []).map((g, i) => (
            <div key={g.label || i} className={`flex flex-col justify-between shrink-0 px-2.5 ${i ? 'border-l border-[var(--leon-line)]' : ''}`}>
              <div className="flex items-center gap-1 flex-wrap min-h-[30px]">{g.items}</div>
              {/* The group label under the controls, as in every Office ribbon.
                  It is what turns a row of buttons into a place you can aim. */}
              <div className="text-[9px] uppercase tracking-[0.14em] text-[var(--leon-black)]/35 text-center pt-1 select-none">
                {g.label}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// Address lookup — type, pick, and the address is set
// ---------------------------------------------------------------------------
// WHY NOT GOOGLE, BY DEFAULT. Google Places needs an API key, and this app is a
// page with no backend: a key put here is readable by anyone who opens it, and
// it is billable. That is the same constraint that keeps email queued rather
// than sent. So the default provider is OpenStreetMap's Nominatim, which needs
// no key and works today; a Google key can be supplied under Admin Settings by
// whoever is willing to own it, and the screen says what that means.
//
// Nominatim's usage policy asks for at most one request a second and no bulk
// use. The debounce below is what honours that — do not shorten it, and do not
// fire on every keystroke.
const ADDRESS_MIN_CHARS = 4;
const ADDRESS_DEBOUNCE_MS = 700;

function addressProviderKey() {
  try { return localStorage.getItem('leon-google-places-key') || ''; } catch (e) { return ''; }
}

// One shape out of either provider: what to show, what to store, and the parts
// worth keeping — the state in particular, because the sales-tax suggestion
// reads it and `stateFromAddress` returns null whenever it is unsure.
function addressNormalizeOsm(r) {
  const a = r.address || {};
  const state = a['ISO3166-2-lvl4'] ? String(a['ISO3166-2-lvl4']).split('-')[1] : '';
  return {
    label: r.display_name || '',
    line: [[a.house_number, a.road].filter(Boolean).join(' '),
           a.city || a.town || a.village || a.suburb || '',
           state, a.postcode || ''].filter(Boolean).join(', '),
    city: a.city || a.town || a.village || '', state: state,
    postcode: a.postcode || '', country: a.country || '',
    lat: r.lat ? Number(r.lat) : null, lon: r.lon ? Number(r.lon) : null,
  };
}

async function addressSearch(q, signal) {
  const key = addressProviderKey();
  if (key) {
    // Google's own autocomplete, on the team's key. Kept behind an explicit
    // opt-in rather than shipped, for the reason above.
    const u = 'https://maps.googleapis.com/maps/api/place/autocomplete/json?types=address&input='
      + encodeURIComponent(q) + '&key=' + encodeURIComponent(key);
    const r = await fetch(u, { signal });
    const j = await r.json();
    return (j.predictions || []).map(p => ({ label: p.description, line: p.description,
      city: '', state: '', postcode: '', country: '', lat: null, lon: null }));
  }
  const u = 'https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=6&q='
    + encodeURIComponent(q);
  const r = await fetch(u, { signal, headers: { 'Accept': 'application/json' } });
  if (!r.ok) throw new Error('Address lookup returned ' + r.status);
  const j = await r.json();
  return j.map(addressNormalizeOsm);
}

// A suggestion, never a gate: what is typed is always kept, whether or not it
// matches anything. Plenty of real jobsites are a lot number on a road that has
// no name yet.
function AddressAutocomplete({ value, onChange, onPick, disabled, placeholder, className }) {
  const [q, setQ] = useState(value || '');
  const [list, setList] = useState([]);
  const [open, setOpen] = useState(false);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');
  const [hi, setHi] = useState(-1);
  const box = useRef(null);
  const abort = useRef(null);
  useEffect(() => { setQ(value || ''); }, [value]);
  useEffect(() => {
    function away(e) { if (box.current && !box.current.contains(e.target)) setOpen(false); }
    document.addEventListener('mousedown', away);
    return () => document.removeEventListener('mousedown', away);
  }, []);
  useEffect(() => {
    if (disabled) return;
    const s = (q || '').trim();
    if (s.length < ADDRESS_MIN_CHARS || s === (value || '').trim()) { setList([]); return; }
    const t = setTimeout(async () => {
      if (abort.current) abort.current.abort();
      const ac = new AbortController(); abort.current = ac;
      setBusy(true); setErr('');
      try {
        const res = await addressSearch(s, ac.signal);
        setList(res); setOpen(true); setHi(-1);
      } catch (e) {
        if (e.name !== 'AbortError') { setErr('Address lookup is unavailable — type it in full.'); setList([]); }
      } finally { setBusy(false); }
    }, ADDRESS_DEBOUNCE_MS);
    return () => clearTimeout(t);
  }, [q, disabled]);
  const choose = r => {
    const text = r.line || r.label;
    setQ(text); setOpen(false); setList([]);
    if (onChange) onChange(text);
    if (onPick) onPick(r);
  };
  return (
    <div className={`relative ${className || ''}`} ref={box}>
      <TextInput value={q} disabled={disabled} placeholder={placeholder || 'Start typing an address\u2026'}
        onChange={e => { setQ(e.target.value); if (onChange) onChange(e.target.value); }}
        onFocus={() => { if (list.length) setOpen(true); }}
        onKeyDown={e => {
          if (!open || !list.length) return;
          if (e.key === 'ArrowDown') { e.preventDefault(); setHi(i => Math.min(i + 1, list.length - 1)); }
          else if (e.key === 'ArrowUp') { e.preventDefault(); setHi(i => Math.max(i - 1, 0)); }
          else if (e.key === 'Enter' && hi >= 0) { e.preventDefault(); choose(list[hi]); }
          else if (e.key === 'Escape') setOpen(false);
        }} />
      {busy && <span className="absolute right-2 top-1.5 text-[10px] text-[var(--leon-black)]/35">searching\u2026</span>}
      {err && <p className="text-[10px] text-[var(--leon-red)] mt-0.5">{err}</p>}
      {open && !!list.length && (
        <div className="absolute z-30 left-0 right-0 mt-1 bg-white border border-[var(--leon-line)] rounded-lg shadow-lg max-h-64 overflow-y-auto">
          {list.map((r, i) => (
            <button key={i} type="button" onMouseEnter={() => setHi(i)} onClick={() => choose(r)}
              className={`w-full text-left px-2.5 py-1.5 text-xs border-b border-[var(--leon-line)] last:border-0 ${i === hi ? 'bg-[var(--leon-cream)]' : ''}`}>
              <div className="font-semibold">{r.line || r.label}</div>
              {r.line && r.label !== r.line && (
                <div className="text-[10px] text-[var(--leon-black)]/45 truncate">{r.label}</div>
              )}
            </button>
          ))}
          <div className="px-2.5 py-1 text-[9px] text-[var(--leon-black)]/35 bg-[var(--leon-cream)]/60">
            {addressProviderKey() ? 'Google Places' : 'Addresses \u00a9 OpenStreetMap contributors'}
          </div>
        </div>
      )}
    </div>
  );
}
