/* global React, Ic, CloudGate, PageHeader, CrudToolbar, RowActions, AuditCell, Pill, Drawer, TextField, SelectField, TextAreaField, FieldRow, CustomFieldsSection, gradFor, initialsOf, RemoteView */
const { useState: useState7, useRef: useRef7, useEffect: useEffect7 } = React;

// =================== ASSETS ===================
// Virtualised, random-access table.  The Assets table can hold tens of
// thousands of rows, so we never render them all into the DOM and we never
// require the user to scroll through every preceding page just to reach
// row 25 000:
//   • Rows are stored sparsely in a Map keyed by absolute row index.  A page
//     of 500 rows is fetched the moment any of its indices enters the
//     overscan band.  Pages out of order are fine.
//   • Whatever pages cover the current viewport (+ one page of prefetch on
//     each side) are kept in-flight; the rest is empty until you scroll.
//   • Dragging the scrollbar straight to row 30 000 fetches only the one
//     page that covers it; you don't pay for the 60 pages in between.
//   • Any filter change cancels in-flight fetches via a generation token,
//     clears the Map, resets scroll, and starts over.
function AssetsPage() {
  const PAGE_SIZE      = 500;   // server max; one fetch covers a long scroll
  const ROW_HEIGHT     = 44;    // px — keep in sync with the inline tr height
  const OVERSCAN_ROWS  = 8;     // extra rows above / below the viewport
  const PREFETCH_PAGES = 1;     // load this many pages ahead of / behind visible

  const [total, setTotal]                 = useState7(0);
  const [rowsByIndex, setRowsByIndex]     = useState7(() => new Map());
  const [loadedPages, setLoadedPages]     = useState7(() => new Set());
  const [loadingFirst, setLoadingFirst]   = useState7(false);

  const [q, setQ]                         = useState7('');
  const [tagFilter, setTagFilter]         = useState7('all');
  const [statusFilter, setStatusFilter]   = useState7('all');
  const [locFilter, setLocFilter]         = useState7('all');

  const [editing, setEditing]             = useState7(null);
  const [showNew, setShowNew]             = useState7(false);
  const [showImport, setShowImport]       = useState7(false);

  const [scrollTop, setScrollTop]             = useState7(0);
  const [viewportHeight, setViewportHeight]   = useState7(600);

  // Refs that don't trigger renders.
  const fetchTokenRef    = useRef7(0);                 // bumped per reload; stale responses are dropped
  const inFlightRef      = useRef7(new Set());         // pages currently being fetched (not stale)
  const debounceRef      = useRef7(null);
  const scrollRef        = useRef7(null);

  // Filters snapshot — used by every fetch so a mid-flight reload sees the
  // current values (the token guard handles stale responses; this just
  // ensures the request bodies match the user's latest intent).
  const filtersForFetch = (override = {}) => ({
    q:           ((override.q          ?? q) || '').trim(),
    tagTypeId:   ((override.tagFilter    ?? tagFilter)    === 'all') ? null : +(override.tagFilter    ?? tagFilter),
    statusId:    ((override.statusFilter ?? statusFilter) === 'all') ? null : +(override.statusFilter ?? statusFilter),
    locationId:  ((override.locFilter    ?? locFilter)    === 'all') ? null : +(override.locFilter    ?? locFilter),
  });

  // Fetch a single page if not already loaded / in-flight.  Used by the
  // viewport-watch effect below — never call directly from user code.
  const fetchPage = (page) => {
    if (page < 1) return;
    if (loadedPages.has(page))      return;
    if (inFlightRef.current.has(page)) return;
    const token = fetchTokenRef.current;
    inFlightRef.current.add(page);
    CloudGate.fetchAssets({ page, size: PAGE_SIZE, ...filtersForFetch() }).then(resp => {
      if (token !== fetchTokenRef.current) {           // filters changed → drop
        inFlightRef.current.delete(page);
        return;
      }
      const start = (page - 1) * PAGE_SIZE;
      setRowsByIndex(prev => {
        const next = new Map(prev);
        (resp.Rows || []).forEach((row, i) => next.set(start + i, row));
        return next;
      });
      if (typeof resp.Total === 'number') setTotal(resp.Total);
      setLoadedPages(prev => {
        const next = new Set(prev);
        next.add(page);
        return next;
      });
      inFlightRef.current.delete(page);
    });
  };

  // Fresh load: clear everything, fetch page 1.  Total comes back with the
  // first response and unlocks the viewport-watch effect.
  const reloadAll = (override = {}) => {
    ++fetchTokenRef.current;                            // invalidate every in-flight
    inFlightRef.current = new Set();
    setLoadingFirst(true);
    setRowsByIndex(new Map());
    setLoadedPages(new Set());
    setTotal(0);
    if (scrollRef.current) scrollRef.current.scrollTop = 0;
    setScrollTop(0);
    const token = fetchTokenRef.current;
    inFlightRef.current.add(1);
    CloudGate.fetchAssets({ page: 1, size: PAGE_SIZE, ...filtersForFetch(override) }).then(resp => {
      if (token !== fetchTokenRef.current) return;
      const map = new Map();
      (resp.Rows || []).forEach((row, i) => map.set(i, row));
      setRowsByIndex(map);
      setLoadedPages(new Set([1]));
      setTotal(resp.Total || 0);
      inFlightRef.current.delete(1);
      setLoadingFirst(false);
    });
  };

  // Re-fetch from scratch whenever a filter changes (debounced for typing).
  useEffect7(() => {
    if (debounceRef.current) clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => reloadAll(), 200);
    return () => debounceRef.current && clearTimeout(debounceRef.current);
    // eslint-disable-next-line
  }, [q, tagFilter, statusFilter, locFilter]);

  // ── Viewport / virtualisation maths ─────────────────────────────────────
  const firstVisible = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN_ROWS);
  const visibleSlots = Math.ceil(viewportHeight / ROW_HEIGHT) + OVERSCAN_ROWS * 2;
  const lastVisible  = Math.min(total, firstVisible + visibleSlots);
  const topPadPx     = firstVisible * ROW_HEIGHT;
  // Bottom padding stretches to `total` so the scrollbar always reflects
  // the full dataset; unloaded indices in the visible band render as
  // placeholder rows so the layout doesn't jump as pages stream in.
  const bottomPadPx  = Math.max(0, (total - lastVisible) * ROW_HEIGHT);

  // Whenever the visible range or total changes, request any pages that
  // cover it (plus one page of prefetch on each side).  Runs as fetches
  // resolve too, because loadedPages is in the dep list — that lets chains
  // of "page just landed → check if we need another" happen for free.
  useEffect7(() => {
    if (loadingFirst || total === 0) return;
    const startPage = Math.max(1, Math.floor(firstVisible / PAGE_SIZE) + 1 - PREFETCH_PAGES);
    const endPage   = Math.min(
      Math.ceil(total / PAGE_SIZE),
      Math.floor((lastVisible - 1) / PAGE_SIZE) + 1 + PREFETCH_PAGES
    );
    for (let p = startPage; p <= endPage; p++) fetchPage(p);
    // eslint-disable-next-line
  }, [firstVisible, lastVisible, total, loadedPages, loadingFirst]);

  // Track scroll position — kept as state so the math above re-runs.  No
  // throttling needed for typical scroll rates; React batches setState calls
  // and the slice we render is tiny.
  const onScroll = (e) => setScrollTop(e.currentTarget.scrollTop);

  // Keep viewportHeight in sync with the scroll container's actual size.
  useEffect7(() => {
    if (!scrollRef.current) return;
    const update = () => scrollRef.current && setViewportHeight(scrollRef.current.clientHeight);
    update();
    const ro = new ResizeObserver(update);
    ro.observe(scrollRef.current);
    return () => ro.disconnect();
  }, []);

  const save = async (row) => {
    const saved = await CloudGate.apiSave('assets', row);
    if (!saved) return;
    setEditing(null); setShowNew(false);
    reloadAll();
  };
  const remove = async (id) => {
    if (!await CloudGate.apiDelete('assets', id)) return;
    reloadAll();
  };
  const reloadAfterImport = () => { setShowImport(false); reloadAll(); };

  // Build the visible slice: for each index in [firstVisible, lastVisible),
  // either render the loaded row or a placeholder until its page lands.
  const visibleIndices = [];
  for (let i = firstVisible; i < lastVisible; i++) visibleIndices.push(i);
  const loadedCount = rowsByIndex.size;
  const stillLoading = inFlightRef.current.size > 0;
  // Column count for the virtualisation spacer / placeholder rows' colSpan:
  // base 8 + optional Modified (SystemAdmin) + optional Company (Tenant/System).
  const colSpan = 8 + (CloudGate.isAdmin() ? 1 : 0) + (CloudGate.isTenantOrSystemAdmin() ? 1 : 0);

  return (
    <div className="page">
      <PageHeader title="Assets" sub="Tagged inventory tracked across locations."
                  onNew={CloudGate.canEdit() ? () => setShowNew(true) : null}
                  newLabel="New asset"
                  onImport={CloudGate.canOperate() ? () => setShowImport(true) : null}
                  importLabel="Import"
                  onExport={() => {}}/>
      <div className="card">
        <CrudToolbar q={q} onQ={setQ} placeholder="Search by tag…"
          count={loadedCount} total={total}
          filters={<>
            <select className="select" value={tagFilter} onChange={e => setTagFilter(e.target.value)}>
              <option value="all">All tag types</option>
              {CloudGate.DB.TagTypes.map(t => <option key={t.Id} value={t.Id}>{t.Type}</option>)}
            </select>
            <select className="select" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
              <option value="all">All statuses</option>
              {CloudGate.DB.AssetStatuses.map(s => <option key={s.Id} value={s.Id}>{s.Status}</option>)}
            </select>
            <select className="select" value={locFilter} onChange={e => setLocFilter(e.target.value)}>
              <option value="all">All locations</option>
              {CloudGate.byActiveCompany(CloudGate.DB.Locations).map(l => <option key={l.Id} value={l.Id}>{l.Name}</option>)}
            </select>
          </>}/>
        {/* Scroll container — fixed height so the inner table can grow taller
            than the viewport and we can virtualise.  Calc fills most of the
            window minus the surrounding chrome. */}
        <div ref={scrollRef} onScroll={onScroll}
             style={{height:'calc(100vh - 280px)', minHeight:380, overflow:'auto', position:'relative'}}>
          <table className="tbl feature" style={{tableLayout:'fixed', width:'100%'}}>
            <thead style={{position:'sticky', top:0, zIndex:1, background:'var(--surface-1, #fff)'}}>
              <tr>
                <th style={{width:32}}><input type="checkbox"/></th>
                <th>Tag</th>
                <th style={{width:90}}>Type</th>
                <th style={{width:180}}>Asset type</th>
                <th style={{width:180}}>Location</th>
                {/* Company column — Tenant/System admins only (they span companies). */}
                {CloudGate.isTenantOrSystemAdmin() && <th style={{width:160}}>Company</th>}
                <th style={{width:110}}>Status</th>
                <th style={{width:120}}>Last seen</th>
                {CloudGate.isAdmin() && <th style={{width:120}}>Modified</th>}
                <th style={{width:90}}></th>
              </tr>
            </thead>
            <tbody>
              {loadingFirst && (
                <tr><td colSpan={colSpan} style={{textAlign:'center', padding:24, color:'var(--text-muted)', fontSize:12}}>Loading…</td></tr>
              )}
              {!loadingFirst && total === 0 && (
                <tr><td colSpan={colSpan} style={{textAlign:'center', padding:24, color:'var(--text-muted)', fontSize:12}}>No assets match.</td></tr>
              )}
              {!loadingFirst && topPadPx > 0 && (
                <tr aria-hidden="true" style={{height: topPadPx}}><td colSpan={colSpan} style={{padding:0, border:0}}/></tr>
              )}
              {!loadingFirst && visibleIndices.map(i => {
                const r = rowsByIndex.get(i);
                if (!r) {
                  // Page covering this index hasn't landed yet — render a
                  // placeholder row so the table layout stays stable.
                  return (
                    <tr key={'ph-' + i} style={{height: ROW_HEIGHT}}>
                      <td colSpan={colSpan} style={{color:'var(--text-faint)', fontSize:12, fontStyle:'italic'}}>…</td>
                    </tr>
                  );
                }
                const tagType = CloudGate.DB.TagTypes.find(t => t.Id === r.TagTypeId);
                const aType   = CloudGate.DB.AssetTypes.find(t => t.Id === r.AssetTypeId);
                const loc     = CloudGate.DB.Locations.find(l => l.Id === r.LocationId);
                const st      = CloudGate.DB.AssetStatuses.find(s => s.Id === r.StatusId);
                return (
                  <tr key={r.Id} style={{height: ROW_HEIGHT}}>
                    <td><input type="checkbox"/></td>
                    <td><span className="code-chip" style={{fontFamily:'var(--mono)'}}>{r.Tag}</span></td>
                    <td><Pill tone={tagType?.Type === 'Rfid' ? 'brand' : 'cyan'}>{tagType?.Type}</Pill></td>
                    <td style={{fontSize:13}}>{aType?.AssetTypeName || '—'}</td>
                    <td style={{fontSize:13}}>{loc ? loc.Name : '—'}</td>
                    {CloudGate.isTenantOrSystemAdmin() && <td style={{fontSize:13}}>{(CloudGate.DB.Companies.find(c => c.Id === r.CompanyId)?.Name) || '—'}</td>}
                    <td><Pill tone={st?.Status === 'Active' ? 'ok' : 'default'}>{st?.Status}</Pill></td>
                    <td style={{color:'var(--text-muted)', fontSize:12}}>{CloudGate.relTime(r.LastSeen)}</td>
                    {CloudGate.isAdmin() && <td><AuditCell modified={r.Modified}/></td>}
                    <td>{CloudGate.canEdit() && <RowActions onEdit={() => setEditing(r)} onDelete={() => remove(r.Id)}/>}</td>
                  </tr>
                );
              })}
              {!loadingFirst && bottomPadPx > 0 && (
                <tr aria-hidden="true" style={{height: bottomPadPx}}><td colSpan={colSpan} style={{padding:0, border:0}}/></tr>
              )}
            </tbody>
          </table>
        </div>
        {/* Footer — current viewport position + loading indicator. */}
        {total > 0 && (
          <div style={{display:'flex', justifyContent:'space-between', alignItems:'center', padding:'8px 14px', borderTop:'1px solid var(--border)', fontSize:12, color:'var(--text-muted)'}}>
            <span>Rows {(firstVisible + 1).toLocaleString()}–{lastVisible.toLocaleString()} of {total.toLocaleString()}</span>
            <span>{stillLoading ? 'Loading…' : `${loadedCount.toLocaleString()} cached`}</span>
          </div>
        )}
      </div>
      {(editing || showNew) && <AssetDrawer row={editing} onClose={() => { setEditing(null); setShowNew(false); }} onSave={save}/>}
      {showImport && <AssetImportDialog onClose={() => setShowImport(false)} onDone={reloadAfterImport}/>}
    </div>
  );
}
function AssetDrawer({ row, onClose, onSave }) {
  // Pickers are scoped to the active Company so a user editing/creating
  // assets can only attach them to that company's locations and asset
  // types — never anything belonging to another company or tenant.
  const ownTypes = CloudGate.byActiveCompany(CloudGate.DB.AssetTypes);
  const ownLocs  = CloudGate.byActiveCompany(CloudGate.DB.Locations);
  const [form, setForm] = useState7(row || { Tag:'', TagTypeId: 1,
                                              AssetTypeId: ownTypes[0]?.Id ?? null,
                                              LocationId:  ownLocs[0]?.Id  ?? null,
                                              StatusId: 1 });
  // Validation: AssetType is required even though the column is nullable in DB.
  const handleSave = () => {
    if (form.AssetTypeId == null) {
      alert('Please select an Asset type before saving.');
      return;
    }
    onSave(form);
  };
  return (
    <Drawer title={form.Tag || 'New asset'} subtitle={row ? 'Edit asset' : 'New asset'} onClose={onClose} onSave={handleSave} saveLabel={row ? 'Save changes' : 'Create asset'}>
      <TextField label="Tag" value={form.Tag} onChange={v => setForm({...form, Tag:v})} required maxLength={128} hint="EPC (RFID) or barcode value."/>
      <FieldRow>
        <SelectField label="Tag type" value={form.TagTypeId} onChange={v => setForm({...form, TagTypeId:+v})} required
          options={CloudGate.DB.TagTypes.map(t => ({ value: t.Id, label: t.Type }))}/>
        <SelectField label="Status" value={form.StatusId} onChange={v => setForm({...form, StatusId:+v})} required
          options={CloudGate.DB.AssetStatuses.map(s => ({ value: s.Id, label: s.Status }))}/>
      </FieldRow>
      <FieldRow>
        <SelectField label="Asset type" value={form.AssetTypeId ?? ''}
          onChange={v => setForm({...form, AssetTypeId: v === '' ? null : +v})} required
          options={ownTypes.map(t => ({ value: t.Id, label: t.AssetTypeName }))}/>
        <SelectField label="Location" value={form.LocationId ?? ''}
          onChange={v => setForm({...form, LocationId: v === '' ? null : +v})} required
          options={ownLocs.map(l => ({ value: l.Id, label: l.Name }))}/>
      </FieldRow>
      {/* Custom fields belong to the AssetType, not the Asset itself.  Show
          them here as read-only so the operator sees what metadata is carried
          on every Asset of this type without being able to edit it from the
          Asset row. */}
      <AssetTypeFieldsReadOnly assetTypeId={form.AssetTypeId}/>
    </Drawer>
  );
}

// Read-only echo of the selected AssetType's UDF definitions + values.
// Used by both the single-asset drawer and the bulk import dialog so users
// understand which metadata fields will be attached to every imported tag.
function AssetTypeFieldsReadOnly({ assetTypeId }) {
  if (assetTypeId == null) return null;
  const defs = (CloudGate.DB.CustomFields || []).filter(f => f.EntityName === 'AssetType');
  if (defs.length === 0) return null;
  const aType = (CloudGate.DB.AssetTypes || []).find(t => t.Id === assetTypeId);
  const vals  = aType?.FieldValues || {};
  const fmt = (d) => {
    const v = vals[d.Id];
    if (v == null || v === '') return '—';
    if (d.FieldType === 4) return v === true || v === 'true' ? 'true' : 'false';
    return String(v);
  };
  return (
    <div style={{padding:12, border:'1px dashed var(--border-strong)', borderRadius:6, background:'var(--surface-2)'}}>
      <div style={{display:'flex', alignItems:'baseline', gap:8, marginBottom:10}}>
        <span style={{fontSize:11, color:'var(--text-muted)', textTransform:'uppercase', letterSpacing:'0.05em', fontWeight:700}}>Custom fields</span>
        <span style={{fontSize:11, color:'var(--text-faint)'}}>· from Asset type</span>
      </div>
      <div style={{display:'flex', flexDirection:'column', gap:10}}>
        {defs.map(d => (
          <div key={d.Id} className="field">
            <label style={{margin:0}}>
              {d.FieldName}
              <span style={{color:'var(--text-faint)', fontWeight:400}}> · {CloudGate.FIELD_TYPE_LABELS[d.FieldType]}</span>
            </label>
            <input className="input" value={fmt(d)} readOnly disabled
                   style={{background:'var(--surface-3, var(--surface-2))', color:'var(--text-muted)', cursor:'not-allowed'}}/>
          </div>
        ))}
      </div>
    </div>
  );
}

// =================== ASSET IMPORT DIALOG ===================
// Two-step flow against /api/import/assets/{validate,commit}:
//   1. User picks file + product/tag/status/location → Import button calls
//      /validate which returns a report (counts + duplicates) and a CacheKey.
//   2. If the report is clean (or the user accepts the overwrite prompt),
//      /commit streams NDJSON progress events while it bulk-copies the rows.
//      The dialog's Import button becomes Abort during the commit; aborting
//      rolls back the transaction so no partial state lands.
function AssetImportDialog({ onClose, onDone }) {
  const ownTypes = CloudGate.byActiveCompany(CloudGate.DB.AssetTypes);
  const ownLocs  = CloudGate.byActiveCompany(CloudGate.DB.Locations);
  const tagTypes = CloudGate.DB.TagTypes || [];
  const statuses = CloudGate.DB.AssetStatuses || [];

  const [file, setFile]               = useState7(null);
  const [assetTypeId, setAssetTypeId] = useState7(ownTypes[0]?.Id ?? null);
  const [tagTypeId, setTagTypeId]     = useState7(tagTypes[0]?.Id ?? null);
  const [locationId, setLocationId]   = useState7(ownLocs[0]?.Id ?? null);
  const [statusId, setStatusId]       = useState7(statuses[0]?.Id ?? null);

  // 'idle' → 'validating' → ('error' | 'confirm-overwrite' | 'ready' | 'importing' | 'done' | 'aborted')
  const [phase, setPhase]   = useState7('idle');
  const [report, setReport] = useState7(null);   // /validate response
  const [progress, setProgress] = useState7({ processed: 0, total: 0 });
  const [error, setError]   = useState7(null);
  const abortRef            = useRef7(null);

  const canSubmit = file && assetTypeId && tagTypeId && statusId &&
                    (phase === 'idle' || phase === 'error' || phase === 'aborted' || phase === 'done');
  const importing = phase === 'importing';

  const runValidate = async () => {
    setError(null); setReport(null); setPhase('validating');
    const fd = new FormData();
    fd.append('file', file);
    fd.append('AssetTypeId', String(assetTypeId));
    fd.append('TagTypeId',   String(tagTypeId));
    fd.append('StatusId',    String(statusId));
    if (locationId) fd.append('LocationId', String(locationId));
    try {
      const res = await fetch('/api/import/assets/validate', { method: 'POST', body: fd });
      if (!res.ok) {
        const text = await res.text();
        setError(text || ('Validation failed (HTTP ' + res.status + ')'));
        setPhase('error');
        return;
      }
      const rep = await res.json();
      setReport(rep);

      if (rep.CountLimitExceeded) {
        setError('File contains ' + rep.ValidRows.toLocaleString() + ' valid rows — the maximum is ' +
                 rep.CountLimit.toLocaleString() + ' per import. Please split the file.');
        setPhase('error');
        return;
      }
      if ((rep.Invalid && rep.Invalid.length > 0) || (rep.DuplicatesInFile && rep.DuplicatesInFile.length > 0)) {
        setError('File has problems — fix them and try again. See the report below.');
        setPhase('error');
        return;
      }
      if (rep.DuplicatesInDb && rep.DuplicatesInDb.length > 0) {
        setPhase('confirm-overwrite');
        return;
      }
      // Clean — commit immediately.
      await runCommit(rep.CacheKey, false);
    } catch (err) {
      setError('Validation error: ' + err.message);
      setPhase('error');
    }
  };

  const runCommit = async (cacheKey, overwrite) => {
    setPhase('importing'); setProgress({ processed: 0, total: report?.ValidRows ?? 0 }); setError(null);
    const ctrl = new AbortController();
    abortRef.current = ctrl;
    try {
      const res = await fetch('/api/import/assets/commit', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ CacheKey: cacheKey, Overwrite: !!overwrite }),
        signal:  ctrl.signal
      });
      if (!res.ok) {
        const text = await res.text();
        setError(text || ('Import failed (HTTP ' + res.status + ')'));
        setPhase('error');
        return;
      }
      // Read NDJSON stream — one JSON object per newline.
      const reader  = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';
      let finalEvent = null;
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        let nl;
        while ((nl = buffer.indexOf('\n')) >= 0) {
          const line = buffer.slice(0, nl).trim();
          buffer = buffer.slice(nl + 1);
          if (!line) continue;
          try {
            const evt = JSON.parse(line);
            if (evt.phase === 'progress') setProgress({ processed: evt.processed, total: evt.total });
            else if (evt.phase === 'start') setProgress({ processed: 0, total: evt.total });
            else finalEvent = evt;
          } catch (e) { /* ignore malformed lines */ }
        }
      }
      if (finalEvent?.phase === 'done') {
        setPhase('done');
        setProgress({ processed: finalEvent.total, total: finalEvent.total });
      } else if (finalEvent?.phase === 'aborted') {
        setPhase('aborted');
      } else if (finalEvent?.phase === 'error') {
        setError(finalEvent.message || 'Import failed.');
        setPhase('error');
      }
    } catch (err) {
      if (err.name === 'AbortError') setPhase('aborted');
      else { setError('Import error: ' + err.message); setPhase('error'); }
    } finally {
      abortRef.current = null;
    }
  };

  const onAbort = () => { abortRef.current?.abort(); };

  // Once the user confirms an overwrite, run the commit with overwrite=true.
  const onOverwrite = () => {
    if (!report?.CacheKey) return;
    runCommit(report.CacheKey, true);
  };

  // Done state — close + refresh parent's table.
  useEffect7(() => {
    if (phase === 'done') {
      const t = setTimeout(() => onDone && onDone(), 800);
      return () => clearTimeout(t);
    }
  }, [phase]);

  const pct = progress.total > 0 ? Math.round((progress.processed / progress.total) * 100) : 0;

  return (
    <>
      <div className="drawer-overlay" onClick={importing ? undefined : onClose}/>
      <aside className="drawer" style={{ width: 560 }}>
        <div className="drawer-header">
          <div>
            <div style={{fontSize:11, color:'var(--text-muted)', textTransform:'uppercase', letterSpacing:'0.05em', fontWeight:600}}>Import tags</div>
            <div style={{fontSize:16, fontWeight:600, marginTop:2}}>Bulk import</div>
          </div>
          <button className="icon-btn" onClick={importing ? undefined : onClose} disabled={importing}><Ic.x width="16" height="16"/></button>
        </div>
        <div className="drawer-body">
          <div style={{display:'flex', flexDirection:'column', gap:14}}>

            <div className="field">
              <label>File <span style={{color:'var(--err)'}}>*</span></label>
              <input className="input" type="file" accept=".csv,.xls,.xlsx"
                     onChange={e => setFile(e.target.files?.[0] || null)} disabled={importing}/>
              <span className="hint">Accepts .xls, .xlsx and .csv. Max <strong>{(100000).toLocaleString()}</strong> tags per import.
                For EPC tags the first column must contain 12-byte hex values (dashes / case ignored).</span>
            </div>

            <FieldRow>
              <SelectField label="Tag type" value={tagTypeId ?? ''} onChange={v => setTagTypeId(v === '' ? null : +v)} required
                options={tagTypes.map(t => ({ value: t.Id, label: t.Type }))}/>
              <SelectField label="Status" value={statusId ?? ''} onChange={v => setStatusId(v === '' ? null : +v)} required
                options={statuses.map(s => ({ value: s.Id, label: s.Status }))}/>
            </FieldRow>

            <FieldRow>
              <SelectField label="Asset type (product)" value={assetTypeId ?? ''} onChange={v => setAssetTypeId(v === '' ? null : +v)} required
                options={ownTypes.map(t => ({ value: t.Id, label: t.AssetTypeName }))}/>
              <SelectField label="Location" value={locationId ?? ''} onChange={v => setLocationId(v === '' ? null : +v)}
                options={[{ value: '', label: '— none —' }, ...ownLocs.map(l => ({ value: l.Id, label: l.Name }))]}/>
            </FieldRow>

            <AssetTypeFieldsReadOnly assetTypeId={assetTypeId}/>

            {/* Status / progress */}
            {phase === 'validating' && (
              <div style={{fontSize:12, color:'var(--text-muted)'}}>Validating file…</div>
            )}

            {importing && (
              <div style={{padding:12, border:'1px solid var(--border)', borderRadius:6, background:'var(--surface-2)'}}>
                <div style={{display:'flex', justifyContent:'space-between', fontSize:12, marginBottom:6}}>
                  <span>Importing… {progress.processed.toLocaleString()} of {progress.total.toLocaleString()}</span>
                  <span>{pct}%</span>
                </div>
                <div style={{height:8, background:'var(--surface-3, var(--border))', borderRadius:4, overflow:'hidden'}}>
                  <div style={{height:'100%', width: pct + '%', background:'var(--brand, #4f46e5)', transition:'width 0.2s ease'}}/>
                </div>
              </div>
            )}

            {phase === 'confirm-overwrite' && report && (
              <div style={{padding:12, border:'1px solid var(--warn, #d97706)', borderRadius:6, background:'var(--warn-soft, oklch(0.95 0.06 75))'}}>
                <div style={{fontWeight:600, marginBottom:6}}>Existing tags found</div>
                <div style={{fontSize:13, color:'var(--text-muted)', marginBottom:10}}>
                  {report.DuplicatesInDb.length.toLocaleString()} tag(s) in this file already exist in the database for this company.
                  Overwriting will update their Asset type, Tag type, Location and Status to the values selected above (the same Asset Id is kept).
                </div>
                <div style={{display:'flex', gap:8}}>
                  <button className="btn primary" onClick={onOverwrite}>Overwrite {report.DuplicatesInDb.length.toLocaleString()} tag(s)</button>
                  <button className="btn" onClick={() => { setPhase('idle'); setReport(null); }}>Cancel</button>
                </div>
              </div>
            )}

            {phase === 'done' && (
              <div style={{padding:12, border:'1px solid var(--ok, #16a34a)', borderRadius:6, background:'var(--ok-soft, oklch(0.95 0.06 155))'}}>
                Import complete — {progress.total.toLocaleString()} tag(s) processed.
              </div>
            )}

            {phase === 'aborted' && (
              <div style={{padding:12, border:'1px solid var(--warn, #d97706)', borderRadius:6, background:'var(--warn-soft, oklch(0.95 0.06 75))'}}>
                Import aborted — no changes were saved to the database.
              </div>
            )}

            {error && (
              <div style={{padding:12, border:'1px solid var(--err, #dc2626)', borderRadius:6, background:'var(--err-soft, oklch(0.95 0.06 25))', whiteSpace:'pre-wrap', fontSize:13}}>
                {error}
              </div>
            )}

            {/* Report breakdown when /validate found problems. */}
            {report && (report.Invalid?.length || report.DuplicatesInFile?.length || report.DuplicatesInDb?.length) ? (
              <details style={{fontSize:12, color:'var(--text-muted)'}}>
                <summary style={{cursor:'pointer', userSelect:'none'}}>
                  Validation report — {report.TotalRows} total · {report.ValidRows} valid
                </summary>
                <div style={{marginTop:8, display:'flex', flexDirection:'column', gap:6}}>
                  {report.Invalid?.length > 0 && (
                    <div><strong>{report.Invalid.length}</strong> invalid row(s).
                      <ul style={{margin:'4px 0 0 18px'}}>
                        {report.Invalid.slice(0, 20).map((x, i) => <li key={i}>Row {x.Row}: “{x.Value}” — {x.Reason}</li>)}
                        {report.Invalid.length > 20 && <li>… and {report.Invalid.length - 20} more</li>}
                      </ul>
                    </div>
                  )}
                  {report.DuplicatesInFile?.length > 0 && (
                    <div><strong>{report.DuplicatesInFile.length}</strong> duplicate(s) within the file.
                      <ul style={{margin:'4px 0 0 18px'}}>
                        {report.DuplicatesInFile.slice(0, 20).map((x, i) => <li key={i}>Row {x.Row}: {x.Value} — {x.Reason}</li>)}
                        {report.DuplicatesInFile.length > 20 && <li>… and {report.DuplicatesInFile.length - 20} more</li>}
                      </ul>
                    </div>
                  )}
                  {report.DuplicatesInDb?.length > 0 && (
                    <div><strong>{report.DuplicatesInDb.length}</strong> already in the database.</div>
                  )}
                </div>
              </details>
            ) : null}

          </div>
        </div>
        <div style={{padding:14, borderTop:'1px solid var(--border)', display:'flex', justifyContent:'flex-end', gap:8}}>
          <button className="btn" onClick={onClose} disabled={importing}>Close</button>
          {importing
            ? <button className="btn" style={{borderColor:'var(--err)', color:'var(--err)'}} onClick={onAbort}>Abort</button>
            : <button className="btn primary" onClick={runValidate} disabled={!canSubmit}>Import</button>}
        </div>
      </aside>
    </>
  );
}

// =================== READERS ADMIN (CRUD) ===================
function ReadersAdminPage() {
  const [rows, setRows] = useState7(CloudGate.byActiveCompany(CloudGate.DB.Readers));
  const [q, setQ] = useState7('');
  const [typeFilter, setTypeFilter] = useState7('all');
  const [statusFilter, setStatusFilter] = useState7('all');
  const [editing, setEditing] = useState7(null);
  const [showNew, setShowNew] = useState7(false);
  const [remoteReader, setRemoteReader] = useState7(null);     // Reader currently in the remote-view overlay
  const [hostStatus,   setHostStatus]   = useState7({});       // readerId → bool (true = host online)

  // Poll host-online state for every visible reader every 5s while the page
  // is open.  We could push this via the hub instead — Phase 2 polish.
  useEffect7(() => {
    let cancelled = false;
    const poll = async () => {
      if (cancelled) return;
      const result = {};
      await Promise.all(rows.map(async (r) => {
        // The host-registration key is the reader's HardwareId (matches the
        // OmniGate host's SignalRConfig.HardwareId).  No HardwareId ⇒ no host
        // binding ⇒ always offline; skip the round-trip.
        const hw = (r.HardwareId || '').trim();
        if (!hw) return;
        try {
          const res  = await fetch(`/api/remote/host-status?hardwareId=${encodeURIComponent(hw)}`);
          if (!res.ok) return;
          const body = await res.json();
          result[r.Id] = !!body.Online;
        } catch (_) { /* ignore */ }
      }));
      if (!cancelled) setHostStatus(result);
    };
    poll();
    const t = setInterval(poll, 5_000);
    return () => { cancelled = true; clearInterval(t); };
  }, [rows]);

  // Auto-refresh the stored "Configuration (JSON)" when a device uploads a new
  // settings.json.  The upload bumps the reader's Modified server-side; we poll
  // each reader's settings stamp and, when Modified changes, pull the new value
  // into the list + global cache so the drawer shows it without a page reload.
  // Admin-only (the field itself is SystemAdmin / TenantAdmin); other roles skip
  // the poll entirely (the endpoint would 403 anyway).
  useEffect7(() => {
    if (!CloudGate.isTenantOrSystemAdmin()) return;
    let cancelled = false;
    const sameTime = (a, b) =>
      (a ? new Date(a).getTime() : 0) === (b ? new Date(b).getTime() : 0);
    const poll = async () => {
      if (cancelled) return;
      await Promise.all(rows.map(async (r) => {
        try {
          const res = await fetch(`/api/readers/${r.Id}/settings`);
          if (!res.ok) return;
          const s = await res.json();
          if (sameTime(s.Modified, r.Modified)) return;   // unchanged — nothing to do
          const apply = rd => rd.Id === r.Id ? { ...rd, Settings: s.Settings, Modified: s.Modified } : rd;
          if (!cancelled) setRows(rs => rs.map(apply));
          CloudGate.DB.Readers = CloudGate.DB.Readers.map(apply);
        } catch (_) { /* ignore — try again next tick */ }
      }));
    };
    poll();
    const t = setInterval(poll, 12_000);
    return () => { cancelled = true; clearInterval(t); };
  }, [rows]);

  const filtered = rows.filter(r =>
    (!q || r.Name.toLowerCase().includes(q.toLowerCase()) || (r.HardwareId || '').toLowerCase().includes(q.toLowerCase()) || (r.SerialNumber || '').toLowerCase().includes(q.toLowerCase())) &&
    (typeFilter === 'all' || r.ReaderTypeId === +typeFilter) &&
    (statusFilter === 'all' || r.ReaderStatusId === +statusFilter)
  );

  const save = async (row) => {
    const saved = await CloudGate.apiSave('readers', row);
    if (!saved) return;
    setRows(rs => row.Id ? rs.map(r => r.Id === saved.Id ? saved : r) : [...rs, saved]);
    CloudGate.DB.Readers = row.Id
      ? CloudGate.DB.Readers.map(r => r.Id === saved.Id ? saved : r)
      : [...CloudGate.DB.Readers, saved];
    setEditing(null); setShowNew(false);
  };
  const remove = async (id) => {
    if (!await CloudGate.apiDelete('readers', id)) return;
    setRows(rs => rs.filter(r => r.Id !== id));
    CloudGate.DB.Readers = CloudGate.DB.Readers.filter(r => r.Id !== id);
  };

  return (
    <div className="page">
      <PageHeader title="Readers" sub="Manage reader hardware records, identifiers and configuration."
                  onNew={CloudGate.canEditDevices() ? () => setShowNew(true) : null}
                  newLabel="New reader" onExport={() => {}}/>
      <div className="card">
        <CrudToolbar q={q} onQ={setQ} placeholder="Search by name, MAC or serial…" count={filtered.length} total={rows.length}
          filters={<>
            <select className="select" value={typeFilter} onChange={e => setTypeFilter(e.target.value)}>
              <option value="all">All types</option>
              {CloudGate.DB.ReaderTypes.map(t => <option key={t.Id} value={t.Id}>{t.Type}</option>)}
            </select>
            <select className="select" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
              <option value="all">All statuses</option>
              {CloudGate.DB.ReaderStatuses.map(s => <option key={s.Id} value={s.Id}>{s.Status}</option>)}
            </select>
          </>}/>
        <table className="tbl feature">
          <thead><tr>
            <th style={{width:32}}><input type="checkbox"/></th>
            <th>Reader</th>
            <th>Type</th>
            <th>Hardware ID</th>
            <th>Serial #</th>
            <th>Location</th>
            {/* Company column — Tenant/System admins only (they see readers
                across companies; a customer is scoped to their own). */}
            {CloudGate.isTenantOrSystemAdmin() && <th>Company</th>}
            <th title="Live SignalR hub connection. Operator-set Maintenance / Error flags from the DB take precedence; otherwise the pill reflects whether the OmniGate / GateScan host attached to this reader is currently registered on the /peer hub.">Status</th>
            <th>Last seen</th>
            {CloudGate.isAdmin() && <th>Modified</th>}
            <th style={{width:90}}></th>
          </tr></thead>
          <tbody>
            {filtered.map(r => {
              const type = CloudGate.DB.ReaderTypes.find(t => t.Id === r.ReaderTypeId);
              const st = CloudGate.DB.ReaderStatuses.find(s => s.Id === r.ReaderStatusId);
              const loc = CloudGate.DB.Locations.find(l => l.Id === r.LocationId);
              // Merge live hub state into the Status pill:
              //   • DB-level Error / Maintenance always win (operator or system
              //     flagged the row — never silently overwrite that).
              //   • Otherwise show the live /peer-hub state for the host whose
              //     SignalRConfig.HardwareId matches this reader's HardwareId
              //     (offline when blank). Updates via the 5s poll above.
              const dbStatus    = st?.Status ?? 'Unknown';
              const dbOverrides = dbStatus === 'Error' || dbStatus === 'Maintenance';
              const liveLabel   = hostStatus[r.Id] ? 'Online' : 'Offline';
              const statusLabel = dbOverrides ? dbStatus : liveLabel;
              const tone = statusLabel === 'Online' ? 'ok'
                         : statusLabel === 'Error'  ? 'err'
                         : 'default';
              return (
                <tr key={r.Id}>
                  <td><input type="checkbox"/></td>
                  <td>
                    <div className="cell-id">
                      <div className="cell-avatar" style={{background: gradFor(r.Name)}}><Ic.radio width="14" height="14"/></div>
                      <div className="meta">
                        <div className="name">{r.Name}</div>
                        {CloudGate.isAdmin() && <div className="sub">ID #{r.Id}</div>}
                      </div>
                    </div>
                  </td>
                  <td><Pill tone="brand">{type?.Type}</Pill></td>
                  <td>{r.HardwareId ? <span className="code-chip" style={{fontFamily:'var(--mono)'}}>{r.HardwareId}</span> : <span style={{color:'var(--text-faint)'}}>—</span>}</td>
                  <td style={{fontFamily:'var(--mono)', fontSize:12, color:'var(--text-muted)'}}>{r.SerialNumber || '—'}</td>
                  <td style={{fontSize:13}}>{loc ? loc.Name : '—'}</td>
                  {CloudGate.isTenantOrSystemAdmin() &&
                    <td style={{fontSize:13}}>{(CloudGate.DB.Companies.find(c => c.Id === (loc?.CompanyId ?? r.CompanyId))?.Name) || '—'}</td>}
                  <td><Pill tone={tone}>{statusLabel}</Pill></td>
                  <td style={{color:'var(--text-muted)', fontSize:12}}>{CloudGate.relTime(r.LastSeen)}</td>
                  {CloudGate.isAdmin() && <td><AuditCell modified={r.Modified}/></td>}
                  <td>
                    <div style={{display:'flex', justifyContent:'flex-end', alignItems:'center', gap:4}}>
                      {/* Remote button — opens the live overlay onto this reader's
                          OmniGate host.  Always rendered for every role; disabled
                          when the host isn't connected to the hub right now. */}
                      <button className="icon-btn"
                              title={hostStatus[r.Id] ? 'Open remote view' : 'Host offline'}
                              disabled={!hostStatus[r.Id]}
                              onClick={(e) => { e.stopPropagation(); setRemoteReader(r); }}
                              style={hostStatus[r.Id]
                                ? { color:'var(--brand, #6366f1)' }
                                : { opacity:0.4, cursor:'not-allowed' }}>
                        {Ic?.radio ? <Ic.radio width="14" height="14"/> : '◉'}
                      </button>
                      {CloudGate.canEditDevices() && <RowActions onEdit={() => setEditing(r)} onDelete={() => remove(r.Id)}/>}
                    </div>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      {(editing || showNew) && <ReaderAdminDrawer row={editing} onClose={() => { setEditing(null); setShowNew(false); }} onSave={save}/>}
      {remoteReader && <RemoteView reader={remoteReader} onClose={() => setRemoteReader(null)}/>}
    </div>
  );
}
function ReaderAdminDrawer({ row, onClose, onSave }) {
  // Reader.Location must belong to the active Company — same scope rule as
  // AssetDrawer.
  const ownLocs = CloudGate.byActiveCompany(CloudGate.DB.Locations);
  const [form, setForm] = useState7(row || { Name:'', HardwareId:'', SerialNumber:'', ReaderTypeId: 1,
                                              LocationId: ownLocs[0]?.Id ?? null,
                                              ReaderStatusId: 1, Settings: '' });

  // Saving an edited "Configuration (JSON)" pushes it down to the device and
  // restarts it (server-side, and only when that host is currently online).
  // Warn before that happens, and block obviously-broken JSON up front so it
  // can never reach a live reader.
  const handleSave = () => {
    const changed = (form.Settings || '') !== (row?.Settings || '');
    if (changed && (form.HardwareId || '').trim()) {
      const txt = (form.Settings || '').trim();
      if (txt) { try { JSON.parse(txt); } catch { alert('Configuration is not valid JSON — fix it before saving.'); return; } }
      if (!window.confirm('This overwrites settings.json on the device and RESTARTS it (only if the host is online now). Continue?')) return;
    }
    onSave(form);
  };

  // "Get from device": ask the live host to upload its current settings.json,
  // then poll the reader's settings until the new value lands and show it.
  const [reqStatus, setReqStatus] = useState7('');
  const requestFromDevice = async () => {
    if (!row?.Id) return;
    setReqStatus('Requesting…');
    const tof = m => (m ? new Date(m).getTime() : 0);
    try {
      // Capture the current Modified so we can detect the device's upload even
      // when it re-sends byte-identical settings (the server still bumps Modified).
      let beforeMod = 0;
      try { beforeMod = tof((await (await fetch(`/api/readers/${row.Id}/settings`)).json()).Modified); } catch {}

      const res = await fetch(`/api/remote/request-settings/${row.Id}`, { method: 'POST' });
      if (!res.ok) { setReqStatus('Request failed'); return; }
      const body = await res.json();
      if (!body.online) { setReqStatus('Device is offline'); return; }

      setReqStatus('Waiting for device…');
      for (let i = 0; i < 8; i++) {
        await new Promise(r => setTimeout(r, 1500));
        const s = await (await fetch(`/api/readers/${row.Id}/settings`)).json();
        if (tof(s.Modified) > beforeMod) {
          setForm(f => ({ ...f, Settings: s.Settings }));
          CloudGate.DB.Readers = CloudGate.DB.Readers.map(rd =>
            rd.Id === row.Id ? { ...rd, Settings: s.Settings, Modified: s.Modified } : rd);
          setReqStatus('Updated from device ✓');
          return;
        }
      }
      setReqStatus('No response from device (timed out)');
    } catch { setReqStatus('Request failed'); }
  };
  return (
    <Drawer wide title={form.Name || 'New reader'} subtitle={row ? 'Edit reader' : 'New reader'} onClose={onClose} onSave={handleSave} saveLabel={row ? 'Save changes' : 'Create reader'}>
      <FieldRow>
        <TextField label="Name" value={form.Name} onChange={v => setForm({...form, Name:v})} required maxLength={200}
                   hint="Friendly display name for this reader."/>
        <TextField label="Hardware ID" value={form.HardwareId || ''} onChange={v => setForm({...form, HardwareId:v})} maxLength={200}
                   hint="Host-registration key on the SignalR hub — must match the OmniGate / GateScan host's SignalRConfig.HardwareId. Leave blank if the reader has no host."/>
      </FieldRow>
      <FieldRow>
        <TextField label="Serial number" value={form.SerialNumber || ''} onChange={v => setForm({...form, SerialNumber:v})} maxLength={128}/>
        <SelectField label="Reader type" value={form.ReaderTypeId} onChange={v => setForm({...form, ReaderTypeId:+v})} required
          options={CloudGate.DB.ReaderTypes.map(t => ({ value: t.Id, label: t.Type }))}/>
      </FieldRow>
      <FieldRow>
        <SelectField label="Location" value={form.LocationId ?? ''}
          onChange={v => setForm({...form, LocationId: v === '' ? null : +v})} required
          options={ownLocs.map(l => ({ value: l.Id, label: `${l.code} · ${l.Name}` }))}/>
        <SelectField label="Status" value={form.ReaderStatusId} onChange={v => setForm({...form, ReaderStatusId:+v})} required
          options={CloudGate.DB.ReaderStatuses.map(s => ({ value: s.Id, label: s.Status }))}/>
      </FieldRow>
      {/* Uploaded device settings.json — SystemAdmin / TenantAdmin only.
          "Get from device" pulls the live file; editing + saving pushes it
          back to the device (two-way). */}
      {CloudGate.isTenantOrSystemAdmin() && (
        <>
          {row?.Id && (form.HardwareId || '').trim() && (
            <div style={{display:'flex', alignItems:'center', gap:10, margin:'4px 0 2px'}}>
              <button type="button" className="btn sm" onClick={requestFromDevice}>
                Get from device
              </button>
              {reqStatus && <span style={{fontSize:12, color:'var(--text-muted)'}}>{reqStatus}</span>}
            </div>
          )}
          <TextAreaField label="Configuration (JSON)" value={form.Settings} onChange={v => setForm({...form, Settings:v})}
                         hint="The device's uploaded settings.json. 'Get from device' requests the live file; editing and saving pushes it back to the device and restarts it (only when the host is online)." rows={10}/>
        </>
      )}
      <CustomFieldsSection entityName="Reader" entityId={row?.Id}/>
    </Drawer>
  );
}

// =================== LOCATIONS (re-aligned to schema) ===================
function LocationsAdminPage() {
  const [rows, setRows] = useState7(CloudGate.byActiveCompany(CloudGate.DB.Locations));
  const [q, setQ] = useState7('');
  const [typeFilter, setTypeFilter] = useState7('all');
  const [editing, setEditing] = useState7(null);
  const [showNew, setShowNew] = useState7(false);

  const filtered = rows.filter(r =>
    (!q || r.Name.toLowerCase().includes(q.toLowerCase()) || r.code.toLowerCase().includes(q.toLowerCase())) &&
    (typeFilter === 'all' || r.LocationTypeId === +typeFilter)
  );
  const save = async (row) => {
    const saved = await CloudGate.apiSave('locations', row);
    if (!saved) return;
    setRows(rs => row.Id ? rs.map(r => r.Id === saved.Id ? saved : r) : [...rs, saved]);
    CloudGate.DB.Locations = row.Id
      ? CloudGate.DB.Locations.map(r => r.Id === saved.Id ? saved : r)
      : [...CloudGate.DB.Locations, saved];
    setEditing(null); setShowNew(false);
  };
  const remove = async (id) => {
    if (!await CloudGate.apiDelete('locations', id)) return;
    setRows(rs => rs.filter(r => r.Id !== id));
    CloudGate.DB.Locations = CloudGate.DB.Locations.filter(r => r.Id !== id);
  };

  return (
    <div className="page">
      <PageHeader title="Locations" sub="Physical zones where readers and inventory live." onNew={() => setShowNew(true)} newLabel="New location" onExport={() => {}}/>
      <div className="card">
        <CrudToolbar q={q} onQ={setQ} placeholder="Search by name or code…" count={filtered.length} total={rows.length}
          filters={
            <select className="select" value={typeFilter} onChange={e => setTypeFilter(e.target.value)}>
              <option value="all">All types</option>
              {CloudGate.DB.LocationTypes.map(t => <option key={t.Id} value={t.Id}>{t.LocationTypeName}</option>)}
            </select>
          }/>
        {(() => {
          // Custom fields defined for Location — one column per definition.
          const udfDefs = CloudGate.DB.CustomFields.filter(f => f.EntityName === 'Location');
          const renderUdf = (row, def) => {
            const v = row.FieldValues && row.FieldValues[def.Id];
            if (v === null || v === undefined || v === '') return '—';
            if (def.FieldType === 3) return CloudGate.fmtDate(v);              // DateTime
            if (def.FieldType === 4) return v ? '✓' : '—';                // Bool
            if (def.FieldType === 5) return v ? <a href={v} target="_blank">image</a> : '—'; // Image
            return String(v);
          };
          return (
            <table className="tbl feature">
              <thead><tr>
                <th style={{width:32}}><input type="checkbox"/></th>
                <th style={{width:36}} title="Stock status (red = out of range)"></th>
                <th>Location</th>
                <th>Type</th>
                {/* Company column — Tenant/System admins only (they span companies). */}
                {CloudGate.isTenantOrSystemAdmin() && <th>Company</th>}
                <th className="num">Readers</th>
                <th className="num">Items</th>
                {udfDefs.map(d => <th key={d.Id}>{d.FieldName}</th>)}
                {CloudGate.isAdmin() && <th>Modified</th>}
                <th style={{width:90}}></th>
              </tr></thead>
              <tbody>
                {filtered.map(r => {
                  const type = CloudGate.DB.LocationTypes.find(t => t.Id === r.LocationTypeId);
                  const tone = type?.LocationTypeName === 'Dock' ? 'info' : type?.LocationTypeName === 'Storage' ? 'cyan' : type?.LocationTypeName === 'Process' ? 'pink' : 'brand';
                  const offlineId = CloudGate.DB.ReaderStatuses.find(s => s.Status === 'Offline')?.Id;
                  const anyOffline = offlineId != null && CloudGate.DB.Readers.some(
                    rd => rd.LocationId === r.Id && rd.ReaderStatusId === offlineId
                  );
                  const stockStatus = CloudGate.locationBulletStatus(r.Id);   // 'red' | 'green' | 'black'
                  const dotColor = stockStatus === 'red' ? '#dc2626'
                                : stockStatus === 'green' ? '#16a34a'
                                : '#1f2937';
                  const dotTitle = stockStatus === 'red'   ? 'At least one asset type is outside its (Min, Max) range'
                                 : stockStatus === 'green' ? 'All asset types are within their range'
                                 : 'No stocking targets defined for this location';
                  return (
                    <tr key={r.Id}>
                      <td><input type="checkbox"/></td>
                      <td>
                        <span title={dotTitle}
                              style={{display:'inline-block', width:10, height:10, borderRadius:'50%',
                                      background: dotColor}}/>
                      </td>
                      <td>
                        <div className="cell-id">
                          <div className="cell-avatar" style={{background: gradFor(r.Name)}}>{initialsOf(r.Name)}</div>
                          <div className="meta">
                            <div className="name">{r.Name}</div>
                            {CloudGate.isAdmin() && <div className="sub">ID #{r.Id}</div>}
                          </div>
                        </div>
                      </td>
                      <td><Pill tone={tone}>{type?.LocationTypeName || '—'}</Pill></td>
                      {CloudGate.isTenantOrSystemAdmin() && <td style={{fontSize:13}}>{(CloudGate.DB.Companies.find(c => c.Id === r.CompanyId)?.Name) || '—'}</td>}
                      <td className="num"
                          style={anyOffline ? { color:'#dc2626', fontWeight:700 } : undefined}
                          title={anyOffline ? 'One or more readers are offline' : undefined}>
                        {r.readers}
                      </td>
                      <td className="num">{CloudGate.assetCountByLocation(r.Id).toLocaleString()}</td>
                      {udfDefs.map(d => <td key={d.Id}>{renderUdf(r, d)}</td>)}
                      {CloudGate.isAdmin() && <td><AuditCell modified={r.Modified}/></td>}
                      <td>{CloudGate.canEdit() && <RowActions onEdit={() => setEditing(r)} onDelete={() => remove(r.Id)}/>}</td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          );
        })()}
      </div>
      {(editing || showNew) && <LocationAdminDrawer row={editing} onClose={() => { setEditing(null); setShowNew(false); }} onSave={save}/>}
    </div>
  );
}
function LocationAdminDrawer({ row, onClose, onSave }) {
  // New Locations need an owning Company.  Default to the first Company the
  // current user can see — for CustomerAdmin / Operator / Viewer that's their
  // only one; for TenantAdmin / SystemAdmin it's whichever sits first in the
  // visible list (the user can change it via the Company picker before save).
  const visCos    = CloudGate.visibleCompanies();
  const defaultCo = visCos[0]?.Id;
  const [form, setForm] = useState7(row || { code:'', Name:'', LocationTypeId: 1,
                                               CompanyId: defaultCo,
                                               readers: 0, capacity: 100 });
  return (
    <Drawer title={form.Name || 'New location'} subtitle={row ? 'Edit location' : 'New location'} onClose={onClose} onSave={() => onSave(form)} saveLabel={row ? 'Save changes' : 'Create location'}>
      <FieldRow>
        <TextField label="Code" value={form.code} onChange={v => setForm({...form, code:v})} placeholder="042/01"/>
        <TextField label="Name" value={form.Name} onChange={v => setForm({...form, Name:v})} required maxLength={256}/>
      </FieldRow>
      <FieldRow>
        {/* Owning company. Options are the companies the caller can see, so a
            single-company user only ever has their own; SystemAdmin/TenantAdmin
            can reassign. The server re-checks scope (can't move into a company
            you can't reach). */}
        <SelectField label="Company" value={form.CompanyId ?? ''} onChange={v => setForm({...form, CompanyId:+v})} required
          options={visCos.map(c => ({ value: c.Id, label: c.Name }))}/>
        <SelectField label="Location type" value={form.LocationTypeId} onChange={v => setForm({...form, LocationTypeId:+v})} required
          options={CloudGate.DB.LocationTypes.map(t => ({ value: t.Id, label: t.LocationTypeName }))}/>
      </FieldRow>
      <FieldRow>
        <TextField label="Capacity" value={form.capacity} onChange={v => setForm({...form, capacity:+v})} type="number"/>
      </FieldRow>
      <CustomFieldsSection entityName="Location" entityId={row?.Id}/>
    </Drawer>
  );
}

// =================== CUSTOM FIELDS DEFINITIONS ===================
function CustomFieldsPage() {
  const [rows, setRows] = useState7(CloudGate.DB.CustomFields);
  const [q, setQ] = useState7('');
  const [entityFilter, setEntityFilter] = useState7('all');
  const [editing, setEditing] = useState7(null);
  const [showNew, setShowNew] = useState7(false);

  const ENTITIES = ['Location','LocationType','Company','CompanyType','User','Asset','Reader'];
  const filtered = rows.filter(r =>
    (!q || r.FieldName.toLowerCase().includes(q.toLowerCase())) &&
    (entityFilter === 'all' || r.EntityName === entityFilter)
  );
  // Persist the field DEFINITION to the database via /api/udf/definition.
  // We MUST use the server-returned Id (not Math.max + 1) because that Id is
  // the FK every UDF value row references.  Bumping a local counter produces
  // orphan FieldDefinitionIds and the value POSTs will fail with 404.
  const save = async (row) => {
    try {
      const isUpdate = !!row.Id;
      const url     = isUpdate ? '/api/udf/definition/' + row.Id : '/api/udf/definition';
      const method  = isUpdate ? 'PUT' : 'POST';
      const res = await fetch(url, {
        method,
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({
          EntityName: row.EntityName,
          FieldName:  row.FieldName,
          FieldType:  row.FieldType
        })
      });
      if (!res.ok) {
        const text = await res.text();
        console.warn('[UDF DEF] save failed', res.status, text);
        alert('Could not save field: ' + text);
        return;
      }
      const saved = await res.json();   // { Id, EntityName, FieldName, FieldType }
      console.log('[UDF DEF] saved', saved);
      setRows(rs => isUpdate
        ? rs.map(r => r.Id === saved.Id ? saved : r)
        : [...rs, saved]);
      // Mirror to CloudGate.DB.CustomFields so drawers / other pages see the change.
      CloudGate.DB.CustomFields = isUpdate
        ? CloudGate.DB.CustomFields.map(f => f.Id === saved.Id ? saved : f)
        : [...CloudGate.DB.CustomFields, saved];
    } catch (err) {
      console.warn('[UDF DEF] save error', err);
    } finally {
      setEditing(null); setShowNew(false);
    }
  };

  // RowActions already prompts with a generic confirmation; we override with
  // a more specific message via the row's onDelete wrapper below.
  const remove = async (id) => {
    try {
      const res = await fetch('/api/udf/definition/' + id, { method: 'DELETE' });
      if (!res.ok && res.status !== 404) {
        console.warn('[UDF DEF] delete failed', res.status);
        return;
      }
      console.log('[UDF DEF] deleted id=' + id);
      setRows(rs => rs.filter(r => r.Id !== id));
      CloudGate.DB.CustomFields = CloudGate.DB.CustomFields.filter(f => f.Id !== id);
    } catch (err) {
      console.warn('[UDF DEF] delete error', err);
    }
  };

  return (
    <div className="page">
      <PageHeader title="Custom fields" sub="User-defined field schema — appears on the matching entity's form." onNew={() => setShowNew(true)} newLabel="New field"/>
      <div className="card">
        <CrudToolbar q={q} onQ={setQ} placeholder="Search by field name…" count={filtered.length} total={rows.length}
          filters={
            <select className="select" value={entityFilter} onChange={e => setEntityFilter(e.target.value)}>
              <option value="all">All entities</option>
              {ENTITIES.map(en => <option key={en} value={en}>{en}</option>)}
            </select>
          }/>
        <table className="tbl">
          <thead><tr>
            {CloudGate.isAdmin() && <th style={{width:50}}>#</th>}
            <th>Entity</th>
            <th>Field name</th>
            <th>Type</th>
            {CloudGate.isAdmin() && <th>Modified</th>}
            <th style={{width:90}}></th>
          </tr></thead>
          <tbody>
            {filtered.map(r => (
              <tr key={r.Id}>
                {CloudGate.isAdmin() && <td style={{color:'var(--text-faint)', fontFamily:'var(--mono)', fontSize:12}}>{r.Id}</td>}
                <td><Pill tone="brand">{r.EntityName}</Pill></td>
                <td style={{fontWeight:600}}>{r.FieldName}</td>
                <td><Pill tone="default">{CloudGate.FIELD_TYPE_LABELS[r.FieldType]}</Pill></td>
                {CloudGate.isAdmin() && <td><AuditCell modified={r.Modified}/></td>}
                <td><RowActions onEdit={() => setEditing(r)} onDelete={() => remove(r.Id)}
                                confirmMessage={'Delete custom field "' + r.FieldName + '" and all its stored values across every ' + r.EntityName + ' record?'}/></td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      {(editing || showNew) && (
        <Drawer title={(editing?.FieldName) || 'New custom field'} subtitle={editing ? 'Edit custom field' : 'New custom field'}
          onClose={() => { setEditing(null); setShowNew(false); }}
          onSave={() => save(editing || showNew)}
          saveLabel={editing ? 'Save changes' : 'Create field'}>
          <CustomFieldForm initial={editing} entities={ENTITIES} onChange={(v) => editing ? setEditing(v) : (showNew && setShowNew(v))} value={editing || (showNew === true ? { EntityName:'Location', FieldName:'', FieldType:0 } : showNew)}/>
        </Drawer>
      )}
    </div>
  );
}
function CustomFieldForm({ value, onChange, entities }) {
  const v = value || { EntityName:'Location', FieldName:'', FieldType:0 };
  return (
    <>
      <SelectField label="Entity" value={v.EntityName} onChange={x => onChange({...v, EntityName:x})} required
        options={entities.map(e => ({ value: e, label: e }))}/>
      <TextField label="Field name" value={v.FieldName} onChange={x => onChange({...v, FieldName:x})} required maxLength={128}/>
      <SelectField label="Field type" value={v.FieldType} onChange={x => onChange({...v, FieldType:+x})} required
        options={CloudGate.FIELD_TYPE_LABELS.map((label, i) => ({ value: i, label }))}/>
    </>
  );
}

// =================== ASSET TYPES (admin CRUD) ===================
function AssetTypesPage() {
  const [rows, setRows] = useState7(CloudGate.byActiveCompany(CloudGate.DB.AssetTypes));
  const [q, setQ] = useState7('');
  const [editing, setEditing] = useState7(null);
  const [showNew, setShowNew] = useState7(false);

  const filtered = rows.filter(r => !q || r.AssetTypeName.toLowerCase().includes(q.toLowerCase()));

  // Discover the AssetType UDF columns once per render — these become extra
  // columns in the table and read their value out of each row's FieldValues
  // dictionary (populated by /api/snapshot).
  const udfDefs = (CloudGate.DB.CustomFields || []).filter(f => f.EntityName === 'AssetType');
  const fmtUdf = (def, row) => {
    const v = row?.FieldValues?.[def.Id];
    if (v == null || v === '') return '—';
    if (def.FieldType === 4) return v === true || v === 'true' ? 'true' : 'false';
    if (def.FieldType === 3) return CloudGate.fmtDate(v);
    return String(v);
  };

  const save = async (row) => {
    const saved = await CloudGate.apiSave('assettypes', row);
    if (!saved) return;
    // For a brand-new row the CustomFieldsSection couldn't POST UDF values
    // (no Id yet) — drain them now and patch the saved row's FieldValues so
    // the list view shows them without a snapshot round-trip.
    if (!row.Id) {
      const written = await CloudGate.flushNewUdfs('AssetType', saved.Id);
      if (written && Object.keys(written).length) {
        saved.FieldValues = { ...(saved.FieldValues || {}),
                              ...Object.fromEntries(Object.entries(written).map(([k,v]) => [+k, v])) };
      }
    }
    setRows(rs => row.Id ? rs.map(r => r.Id === saved.Id ? saved : r) : [...rs, saved]);
    CloudGate.DB.AssetTypes = row.Id
      ? CloudGate.DB.AssetTypes.map(r => r.Id === saved.Id ? saved : r)
      : [...CloudGate.DB.AssetTypes, saved];
    setEditing(null); setShowNew(false);
  };
  const remove = async (id) => {
    if (!await CloudGate.apiDelete('assettypes', id)) return;
    setRows(rs => rs.filter(r => r.Id !== id));
    CloudGate.DB.AssetTypes = CloudGate.DB.AssetTypes.filter(r => r.Id !== id);
  };

  return (
    <div className="page">
      <PageHeader title="Asset types" sub="Categories your assets fall into (e.g. Tulip Red 50, Trolley CC)." onNew={() => setShowNew(true)} newLabel="New asset type"/>
      <div className="card">
        <CrudToolbar q={q} onQ={setQ} placeholder="Search by name…" count={filtered.length} total={rows.length}/>
        <table className="tbl">
          <thead><tr>
            {CloudGate.isAdmin() && <th style={{width:50}}>#</th>}
            <th>Name</th>
            {/* Company column — Tenant/System admins only (they span companies). */}
            {CloudGate.isTenantOrSystemAdmin() && <th>Company</th>}
            <th className="num">Assets</th>
            {udfDefs.map(d => <th key={'h-'+d.Id}>{d.FieldName}</th>)}
            {CloudGate.isAdmin() && <th>Modified</th>}
            <th style={{width:90}}></th>
          </tr></thead>
          <tbody>
            {filtered.map(r => {
              const count = CloudGate.assetCountByAssetType(r.Id);
              return (
                <tr key={r.Id}>
                  {CloudGate.isAdmin() && <td style={{color:'var(--text-faint)', fontFamily:'var(--mono)', fontSize:12}}>{r.Id}</td>}
                  <td style={{fontWeight:600}}>{r.AssetTypeName}</td>
                  {CloudGate.isTenantOrSystemAdmin() && <td style={{fontSize:13}}>{(CloudGate.DB.Companies.find(c => c.Id === r.CompanyId)?.Name) || '—'}</td>}
                  <td className="num">{count}</td>
                  {udfDefs.map(d => <td key={'c-'+d.Id+'-'+r.Id} style={{fontSize:13}}>{fmtUdf(d, r)}</td>)}
                  {CloudGate.isAdmin() && <td><AuditCell modified={r.Modified}/></td>}
                  <td>{CloudGate.canEdit() && <RowActions onEdit={() => setEditing(r)} onDelete={() => remove(r.Id)}
                                  confirmMessage={`Delete asset type "${r.AssetTypeName}"? Will fail if any Assets still reference it.`}/>}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      {(editing || showNew) && (
        <Drawer title={editing?.AssetTypeName || 'New asset type'}
                subtitle={editing ? 'Edit asset type' : 'New asset type'}
                onClose={() => { setEditing(null); setShowNew(false); }}
                onSave={() => save(editing || showNew)}
                saveLabel={editing ? 'Save changes' : 'Create'}>
          <AssetTypeForm value={editing || (showNew === true ? { AssetTypeName:'', CompanyId: CloudGate.visibleCompanies()[0]?.Id } : showNew)}
                         onChange={(v) => editing ? setEditing(v) : setShowNew(v)}/>
          <CustomFieldsSection entityName="AssetType" entityId={editing?.Id}/>
        </Drawer>
      )}
    </div>
  );
}
function AssetTypeForm({ value, onChange }) {
  const v = value || { AssetTypeName:'', CompanyId: CloudGate.visibleCompanies()[0]?.Id };
  return (
    <>
      <TextField label="Name" value={v.AssetTypeName} onChange={x => onChange({...v, AssetTypeName:x})} required maxLength={150}/>
    </>
  );
}

// =================== STOCKING TARGETS (per-location editor) ===================
function StockingTargetsPage() {
  // Scope to the active Company — only that company's Locations + AssetTypes
  // are picker options, and only Quantities pointing at this company's
  // Locations are shown.
  const locations = CloudGate.byActiveCompany(CloudGate.DB.Locations);
  const [locId, setLocId] = useState7(locations[0]?.Id ?? null);
  const locIds = new Set(locations.map(l => l.Id));
  const [rows, setRows] = useState7(CloudGate.DB.LocationAssetQuantities.filter(q => locIds.has(q.LocationId)));

  const types = CloudGate.byActiveCompany(CloudGate.DB.AssetTypes);
  const findRow = (atid) => rows.find(q => q.LocationId === locId && q.AssetTypeId === atid);

  const save = async (atid, target, minimum, maximum) => {
    const result = await CloudGate.apiUpsertQty(locId, atid, target, minimum, maximum);
    if (!result) return;
    if (result.deleted) {
      const next = rows.filter(q => !(q.LocationId === locId && q.AssetTypeId === atid));
      setRows(next);
      CloudGate.DB.LocationAssetQuantities = next;
    } else {
      const existing = findRow(atid);
      const next = existing
        ? rows.map(q => q.Id === result.Id ? result : q)
        : [...rows, result];
      setRows(next);
      CloudGate.DB.LocationAssetQuantities = next;
    }
  };

  const activeId = CloudGate.DB.AssetStatuses.find(s => s.Status === 'Active')?.Id;
  const countOf = (atid) => CloudGate.assetCountAt(locId, atid, activeId ?? null);

  return (
    <div className="page">
      <PageHeader title="Stocking targets" sub="Per-location Target / Min / Max counts per asset type. Empty rows are not stored — clear all three numbers to delete a target."/>
      <div className="card">
        <div style={{padding:'10px 14px', borderBottom:'1px solid var(--border)', display:'flex', gap:10, alignItems:'center'}}>
          <span style={{fontSize:12, color:'var(--text-muted)'}}>Location:</span>
          <select className="select" value={locId ?? ''} onChange={e => setLocId(+e.target.value)} style={{minWidth:240}}>
            {locations.map(l => {
              // For Tenant/System admins (who span companies) show which company
              // each location belongs to — the per-page equivalent of the Company
              // column added to the Locations / Asset-types / Assets grids.
              const co = CloudGate.isTenantOrSystemAdmin()
                ? (CloudGate.DB.Companies.find(c => c.Id === l.CompanyId)?.Name)
                : null;
              return <option key={l.Id} value={l.Id}>{co ? `${l.Name} — ${co}` : l.Name}</option>;
            })}
          </select>
        </div>
        <table className="tbl">
          <thead><tr>
            <th>Asset type</th>
            <th className="num" style={{width:110}}>Current</th>
            <th className="num" style={{width:110}}>Target</th>
            <th className="num" style={{width:110}}>Minimum</th>
            <th className="num" style={{width:110}}>Maximum</th>
            <th style={{width:30}} title="Status"></th>
          </tr></thead>
          <tbody>
            {types.map(t => <StockingTargetsRow key={t.Id} type={t} row={findRow(t.Id)} count={countOf(t.Id)} onSave={save}/>)}
            {types.length === 0 && (
              <tr><td colSpan="6" style={{textAlign:'center', padding:'24px 12px', color:'var(--text-muted)', fontSize:12}}>
                No asset types defined yet — create some first under <strong>Asset types</strong>.
              </td></tr>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}
function StockingTargetsRow({ type, row, count, onSave }) {
  const [t, setT] = useState7(row?.Target  ?? '');
  const [mn, setMn] = useState7(row?.Minimum ?? '');
  const [mx, setMx] = useState7(row?.Maximum ?? '');
  // Re-sync when the location changes (parent re-renders with a new `row`)
  useEffect(() => { setT(row?.Target ?? ''); setMn(row?.Minimum ?? ''); setMx(row?.Maximum ?? ''); },
            [row?.Id, row?.Target, row?.Minimum, row?.Maximum]);

  const commit = () => {
    const tn = +t || 0, mnn = +mn || 0, mxn = +mx || 0;
    const before = [row?.Target ?? 0, row?.Minimum ?? 0, row?.Maximum ?? 0];
    if (before[0] === tn && before[1] === mnn && before[2] === mxn) return;   // no change
    onSave(type.Id, tn, mnn, mxn);
  };

  const hasRow = !!row;
  const breach = hasRow && (count < (row.Minimum ?? 0) || count > (row.Maximum ?? 0));
  const dot    = !hasRow ? '#1f2937' : breach ? '#dc2626' : '#16a34a';

  const editable = CloudGate.canEdit();
  const inputStyle = { width:80, textAlign:'right' };
  const numField = (val, setter) => editable
    ? <input className="input" type="number" min="0" value={val} style={inputStyle}
             onChange={e => setter(e.target.value)} onBlur={commit}/>
    : <span style={{color:'var(--text-muted)'}}>{val === '' ? '—' : val}</span>;
  return (
    <tr>
      <td style={{fontWeight:600}}>{type.AssetTypeName}</td>
      <td className="num">{count}</td>
      <td className="num">{numField(t,  setT) }</td>
      <td className="num">{numField(mn, setMn)}</td>
      <td className="num">{numField(mx, setMx)}</td>
      <td><span style={{display:'inline-block', width:10, height:10, borderRadius:'50%', background: dot}}/></td>
    </tr>
  );
}

// =================== TENANTS (SystemAdmin + TenantAdmin) ===================
function TenantsPage() {
  // SystemAdmin sees every Tenant; TenantAdmin only sees their own.
  const __cu = window.__currentUser;
  const isSysAdmin    = __cu?.role === 'SystemAdmin';
  const isTenantAdmin = __cu?.role === 'TenantAdmin';
  const visibleTenants = isSysAdmin
    ? CloudGate.DB.Tenants
    : CloudGate.DB.Tenants.filter(t => t.Id === __cu?.tenantId);

  const [rows, setRows] = useState7(visibleTenants);
  const [q, setQ] = useState7('');
  const [editing, setEditing] = useState7(null);
  const [showNew, setShowNew] = useState7(false);

  const filtered = rows.filter(r => !q || r.TenantName.toLowerCase().includes(q.toLowerCase()));

  const save = async (row) => {
    const saved = await CloudGate.apiSave('tenants', row);
    if (!saved) return;
    setRows(rs => row.Id ? rs.map(r => r.Id === saved.Id ? saved : r) : [...rs, saved]);
    CloudGate.DB.Tenants = row.Id
      ? CloudGate.DB.Tenants.map(r => r.Id === saved.Id ? saved : r)
      : [...CloudGate.DB.Tenants, saved];
    setEditing(null); setShowNew(false);
  };
  const remove = async (id) => {
    if (!await CloudGate.apiDelete('tenants', id)) return;
    setRows(rs => rs.filter(r => r.Id !== id));
    CloudGate.DB.Tenants = CloudGate.DB.Tenants.filter(r => r.Id !== id);
  };

  return (
    <div className="page">
      <PageHeader title="Tenants"
                  sub={isSysAdmin
                    ? "Top-level groupings that own one or more Companies. SystemAdmin can create / rename / delete; TenantAdmin can rename their own."
                    : "Your Tenant. You can rename it; only a System administrator can create or delete tenants."}
                  onNew={isSysAdmin ? () => setShowNew(true) : null}
                  newLabel="New tenant"/>
      <div className="card">
        <CrudToolbar q={q} onQ={setQ} placeholder="Search by name…" count={filtered.length} total={rows.length}/>
        <table className="tbl">
          <thead><tr>
            {CloudGate.isAdmin() && <th style={{width:50}}>#</th>}
            <th>Name</th>
            <th className="num">Companies</th>
            {CloudGate.isAdmin() && <th>Modified</th>}
            <th style={{width:90}}></th>
          </tr></thead>
          <tbody>
            {filtered.map(r => {
              const coCount = CloudGate.DB.Companies.filter(c => c.TenantId === r.Id).length;
              const ownTenant = isTenantAdmin && r.Id === __cu?.tenantId;
              return (
                <tr key={r.Id}>
                  {CloudGate.isAdmin() && <td style={{color:'var(--text-faint)', fontFamily:'var(--mono)', fontSize:12}}>{r.Id}</td>}
                  <td style={{fontWeight:600}}>{r.TenantName}</td>
                  <td className="num">{coCount}</td>
                  {CloudGate.isAdmin() && <td><AuditCell modified={r.Modified}/></td>}
                  <td>
                    {(isSysAdmin || ownTenant) && (
                      <RowActions onEdit={() => setEditing(r)}
                                  onDelete={isSysAdmin ? () => remove(r.Id) : null}
                                  confirmMessage={'Delete tenant "' + r.TenantName + '"? Will fail if any Companies still belong to it.'}/>
                    )}
                  </td>
                </tr>
              );
            })}
            {filtered.length === 0 && (
              <tr><td colSpan={CloudGate.isAdmin() ? 5 : 3} style={{textAlign:'center', padding:'24px 12px', color:'var(--text-muted)', fontSize:12}}>
                No tenants.
              </td></tr>
            )}
          </tbody>
        </table>
      </div>
      {(editing || showNew) && (
        <Drawer title={editing?.TenantName || 'New tenant'}
                subtitle={editing ? 'Edit tenant' : 'New tenant'}
                onClose={() => { setEditing(null); setShowNew(false); }}
                onSave={() => save(editing || showNew)}
                saveLabel={editing ? 'Save changes' : 'Create'}>
          <TenantForm value={editing || (showNew === true ? { TenantName:'' } : showNew)}
                      onChange={(v) => editing ? setEditing(v) : setShowNew(v)}/>
        </Drawer>
      )}
    </div>
  );
}
function TenantForm({ value, onChange }) {
  const v = value || { TenantName:'' };
  return <TextField label="Name" value={v.TenantName} onChange={x => onChange({...v, TenantName:x})} required maxLength={150}/>;
}

// =================== COMPANY PAGES (page visibility) ===================
// Lets a SystemAdmin / TenantAdmin choose which main pages a given Company's
// users (CustomerAdmin / Operator / Viewer / API) can see.  Every page is
// visible by default; unchecking one persists a "hidden" exception.  Only the
// exceptions are stored — see CompanyHiddenPage on the server.
function CompanyPagesPage() {
  const companies = CloudGate.visibleCompanies();
  // Marketing-only pages exist solely in the Demo deployment, so don't offer
  // toggles that would do nothing in this one.
  const pages = CloudGate.COMPANY_PAGES.filter(p => !p.demoOnly || CloudGate.showMarketingUi());

  const [companyId, setCompanyId] = useState7(companies[0]?.Id ?? null);
  // `hidden` is the working set of hidden page keys for the picked company.
  const [hidden, setHidden] = useState7(() => CloudGate.hiddenPagesForCompany(companies[0]?.Id ?? -1));
  const [dirty, setDirty]   = useState7(false);
  const [saving, setSaving] = useState7(false);

  // Re-seed the checkboxes from the snapshot whenever the company changes.
  useEffect7(() => {
    setHidden(CloudGate.hiddenPagesForCompany(companyId ?? -1));
    setDirty(false);
  }, [companyId]);

  const toggle = (key) => {
    setHidden(prev => {
      const next = new Set(prev);
      if (next.has(key)) next.delete(key); else next.add(key);
      return next;
    });
    setDirty(true);
  };

  const save = async () => {
    if (companyId == null) return;
    setSaving(true);
    try {
      const res = await fetch('/api/companies/' + companyId + '/hidden-pages', {
        method:  'PUT',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ HiddenPages: [...hidden] })
      });
      if (!res.ok) { alert('Save failed: ' + await res.text().catch(() => res.status)); return; }
      // Patch the in-memory snapshot so the sidebar (and a re-open of this
      // page) reflect the change without a reload.
      const others = (CloudGate.DB.CompanyHiddenPages || []).filter(h => h.CompanyId !== companyId);
      CloudGate.DB.CompanyHiddenPages = [
        ...others,
        ...[...hidden].map(k => ({ CompanyId: companyId, PageKey: k })),
      ];
      setDirty(false);
    } finally { setSaving(false); }
  };

  return (
    <div className="page">
      <PageHeader title="Company Pages"
                  sub="Choose which main pages each company's users can see. Every page is visible by default; unchecking one hides it from that company's users (CustomerAdmin, Operator, Viewer and API). System and Tenant administrators always see every page."/>
      <div className="card" style={{padding:16, maxWidth:560}}>
        {companies.length === 0 ? (
          <div style={{padding:24, textAlign:'center', color:'var(--text-muted)', fontSize:13}}>No companies to configure.</div>
        ) : (
          <>
            <SelectField label="Company" value={companyId ?? ''}
                         onChange={v => setCompanyId(v === '' ? null : +v)}
                         options={companies.map(c => ({ value:c.Id, label:c.Name }))}/>
            <div style={{marginTop:18}}>
              {pages.map(p => {
                const visible = !hidden.has(p.key);
                return (
                  <label key={p.key}
                         style={{display:'flex', alignItems:'center', gap:10, padding:'10px 4px',
                                 borderBottom:'1px solid var(--border)', cursor:'pointer'}}>
                    <input type="checkbox" checked={visible} onChange={() => toggle(p.key)}
                           style={{width:16, height:16, cursor:'pointer'}}/>
                    <span style={{fontSize:13, fontWeight:500}}>{p.label}</span>
                    {!visible && <span style={{marginLeft:'auto'}}><Pill tone="warn">Hidden</Pill></span>}
                  </label>
                );
              })}
            </div>
            <div style={{marginTop:16, display:'flex', justifyContent:'flex-end'}}>
              <button className="btn primary" disabled={!dirty || saving} onClick={save}>
                {saving ? 'Saving…' : 'Save changes'}
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

window.AssetsPage = AssetsPage;
window.ReadersAdminPage = ReadersAdminPage;
window.LocationsAdminPage = LocationsAdminPage;
window.CustomFieldsPage = CustomFieldsPage;
window.AssetTypesPage = AssetTypesPage;
window.StockingTargetsPage = StockingTargetsPage;
window.TenantsPage = TenantsPage;
window.CompanyPagesPage = CompanyPagesPage;
