// ─── Motor genérico de listas (EntityListScreen) ───────────────────
// Generic list pattern matching Expedientes:
//   eyebrow + title + description + actions
//   KPI tiles
//   optional filter chips
//   search + counter (inside table card)
//   table with sortable columns
//
// Pass a config object to render any list section. Expone a window:
// EntityListScreen, TableDateRange, ExportModal, applyDateRange,
// useEntityStats. Consume window.MicosStore (getStats/fetchPage/SCHEMA),
// window.KpiCard (ui/primitives.jsx), window.LIST_CONFIG, window.MicosExport,
// Icon*. Lo usan 14+ pantallas de lista. P4: TablePill/IdDateCell (definidas
// acá antes, sin consumo interno) se movieron a ui/primitives.jsx; Trend murió
// (0 usos verificados); se borró la feature completa de borrado-por-fila
// (canDeleteRow/onDeleteRow/deleteTitle/deleteDescription/deleteLabel —
// ningún caller la pasaba); applyDateRange pasó de huérfana a consumida
// internamente por el propio filtro de fechas. P6: prop opcional
// `highlightRowId` (resalta una fila puntual con el mismo estilo que ya
// usaba Expedientes para el "recién creado" — inset accent + fondo tenue) —
// aditivo, ningún caller existente lo pasa, cero cambio para ellos.

const { useState: _useStateEL, useMemo: _useMemoEL } = React;

// ─── Main EntityListScreen ────────────────────────

// ─── Reusable: control de rango de fechas (desde–hasta) ─────────
// Usado por EntityListScreen y por las listas a medida (Expedientes, Inventario,
// Finanzas) vía window.TableDateRange. Combina con el buscador (doble filtro).
// Helpers de fecha (un solo lugar). ISO interno (YYYY-MM-DD) para filtrar; se
// muestra DD/MM/AAAA. Las fechas se construyen por partes para evitar TZ.
const _DR_MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const _DR_WD = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sá', 'Do'];
function _drParse(iso) {
  const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''));
  return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null;
}
function _drISO(d) {
  const p = (n) => String(n).padStart(2, '0');
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
function _drFmt(iso) {
  const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''));
  return m ? `${m[3]}/${m[2]}/${m[1]}` : '';
}

// Selector de rango de fechas: UN solo campo (estilo de la app) que abre un
// calendario propio en un popover (portal). Muestra DD/MM/AAAA. La API
// (from/to ISO + onFrom/onTo) no cambia, así todas las listas siguen igual.
function TableDateRange({ from, to, onFrom, onTo }) {
  const [open, setOpen] = React.useState(false);
  const [rect, setRect] = React.useState(null);
  const triggerRef = React.useRef(null);
  const _hoy = new Date();
  const _base = () => _drParse(from) || _drParse(to) || _hoy;
  const [view, setView] = React.useState(() => { const b = _base(); return new Date(b.getFullYear(), b.getMonth(), 1); });

  React.useEffect(() => {
    if (!open) return;
    const sync = () => { if (triggerRef.current) setRect(triggerRef.current.getBoundingClientRect()); };
    sync();
    const close = () => setOpen(false);
    window.addEventListener('resize', close);
    window.addEventListener('scroll', close, true);
    const onDoc = (e) => {
      const inPop = e.target.closest && e.target.closest('.dr-pop');
      if (triggerRef.current && !triggerRef.current.contains(e.target) && !inPop) setOpen(false);
    };
    document.addEventListener('mousedown', onDoc);
    return () => { window.removeEventListener('resize', close); window.removeEventListener('scroll', close, true); document.removeEventListener('mousedown', onDoc); };
  }, [open]);

  const toggle = () => {
    if (!open) { const b = _base(); setView(new Date(b.getFullYear(), b.getMonth(), 1)); }
    setOpen((o) => !o);
  };
  const pick = (iso) => {
    if (!from || (from && to)) { onFrom(iso); onTo(''); }
    else if (iso < from) { onFrom(iso); }
    else { onTo(iso); setOpen(false); }
  };
  const clear = (e) => { if (e) e.stopPropagation(); onFrom(''); onTo(''); };

  const hasVal = !!(from || to);
  const label = hasVal ? `${from ? _drFmt(from) : '…'} – ${to ? _drFmt(to) : '…'}` : 'Rango de fechas';

  const y = view.getFullYear(), m = view.getMonth();
  const startWd = (new Date(y, m, 1).getDay() + 6) % 7; // semana arranca lunes
  const daysInMonth = new Date(y, m + 1, 0).getDate();
  const cells = [];
  for (let i = 0; i < startWd; i++) cells.push(null);
  for (let d = 1; d <= daysInMonth; d++) cells.push(d);
  const todayISO = _drISO(_hoy);

  const pop = (open && rect) ? ReactDOM.createPortal(
    <div className="dr-pop" style={{ top: rect.bottom + 6, left: Math.max(8, Math.min(rect.left, window.innerWidth - 296)) }}>
      <div className="dr-pop__head">
        <button type="button" className="dr-pop__nav" onClick={() => setView(new Date(y, m - 1, 1))} aria-label="Mes anterior"><IconChevronLeft size={16} /></button>
        <span className="dr-pop__title">{`${_DR_MESES[m]} ${y}`}</span>
        <button type="button" className="dr-pop__nav" onClick={() => setView(new Date(y, m + 1, 1))} aria-label="Mes siguiente"><IconChevronRight size={16} /></button>
      </div>
      <div className="dr-pop__grid">
        {_DR_WD.map((w) => <div key={w} className="dr-pop__wd">{w}</div>)}
        {cells.map((d, i) => {
          if (d == null) return <div key={`e${i}`} />;
          const iso = _drISO(new Date(y, m, d));
          const isStart = from && iso === from;
          const isEnd = to && iso === to;
          const inRange = from && to && iso > from && iso < to;
          const cls = ['dr-pop__day'];
          if (iso === todayISO) cls.push('is-today');
          if (inRange) cls.push('in-range');
          if (isStart || isEnd) cls.push('is-sel');
          if (isStart && to) cls.push('range-start');
          if (isEnd && from && to) cls.push('range-end');
          return <button key={iso} type="button" className={cls.join(' ')} onClick={() => pick(iso)}>{d}</button>;
        })}
      </div>
    </div>, document.body) : null;

  return (
    <div className="flex items-center" ref={triggerRef} style={{ position: 'relative' }}>
      <button type="button" onClick={toggle} aria-label="Filtrar por rango de fechas"
        className="h-10 inline-flex items-center gap-2 rounded-lg cursor-pointer text-[12px] font-medium tabular-nums"
        style={{
          background: 'color-mix(in oklab, var(--surface) 50%, transparent)',
          border: `1px solid ${open ? 'color-mix(in oklab, var(--accent) 55%, transparent)' : 'color-mix(in oklab, var(--border) 45%, transparent)'}`,
          color: hasVal ? 'var(--foreground)' : 'var(--muted-foreground)',
          padding: hasVal ? '0 6px 0 12px' : '0 10px 0 12px',
        }}>
        <span>{label}</span>
        {hasVal ? (
          <span role="button" tabIndex={0} title="Limpiar fechas" onClick={clear}
            className="inline-flex items-center justify-center rounded-md cursor-pointer"
            style={{ width: 22, height: 22, color: 'var(--muted-foreground)' }}
            onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)'; e.currentTarget.style.background = 'color-mix(in oklab, var(--accent) 12%, transparent)'; }}
            onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted-foreground)'; e.currentTarget.style.background = 'transparent'; }}>
            <IconX size={12} />
          </span>
        ) : (
          <IconChevronDown size={14} style={{ color: 'var(--muted-foreground)' }} />
        )}
      </button>
      {pop}
    </div>
  );
}

// Filtra filas por rango de fechas sobre un campo ISO (YYYY-MM-DD). Inclusivo.
function applyDateRange(rows, dateField, from, to) {
  if (!dateField || (!from && !to)) return rows;
  return rows.filter((row) => {
    const d = String(row[dateField] || '').slice(0, 10);
    if (!d) return false;
    if (from && d < from) return false;
    if (to && d > to) return false;
    return true;
  });
}

// ─── Reusable: modal de exportación (checklist de columnas → Excel) ─────────
// exportConfig: [{ label, value:(row)=>string|number }]. rows = filas ya
// filtradas. Al confirmar arma el Excel con MicosExport y llama onExported.
function ExportModal({ open, onClose, exportConfig = [], rows = [], filename, onExported }) {
  const [sel, setSel] = React.useState(() => exportConfig.map((_, i) => i));
  React.useEffect(() => { if (open) setSel(exportConfig.map((_, i) => i)); }, [open, exportConfig.length]);
  if (!open) return null;
  const allSel = sel.length === exportConfig.length;
  const toggle = (i) => setSel((s) => (s.includes(i) ? s.filter((x) => x !== i) : [...s, i].sort((a, b) => a - b)));
  const toggleAll = () => setSel(allSel ? [] : exportConfig.map((_, i) => i));
  const doExport = () => {
    const cols = sel.map((i) => exportConfig[i]).filter(Boolean);
    if (!cols.length) return;
    const headers = cols.map((c) => c.label);
    const dataRows = rows.map((row) => cols.map((c) => {
      let v; try { v = c.value ? c.value(row) : row[c.key]; } catch (_) { v = ''; }
      return v == null ? '' : v;
    }));
    let res = { format: 'xlsx' };
    if (window.MicosExport) res = window.MicosExport.exportTable(filename || 'export', headers, dataRows) || res;
    onClose();
    if (onExported) onExported(dataRows.length, res);
  };
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(8,20,24,0.55)' }} onClick={onClose}>
      <div className="rounded-2xl w-full max-w-md animate-fade-in-up" onClick={(e) => e.stopPropagation()}
        style={{ background: 'var(--card)', border: '1px solid var(--border)', boxShadow: '0 24px 60px -16px rgba(8,20,24,0.6)', maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}>
        <div className="px-6 pt-6 pb-1 flex-shrink-0">
          <div className="flex items-center gap-2.5">
            <span className="inline-flex items-center justify-center rounded-lg flex-shrink-0" style={{ width: 30, height: 30, background: 'color-mix(in oklab, var(--accent) 14%, transparent)', color: 'var(--accent)' }}>
              <IconDownload size={15} />
            </span>
            <p className="text-[15px] font-extrabold" style={{ color: 'var(--foreground)' }}>Exportar a Excel</p>
          </div>
          <p className="text-[12.5px] mt-2.5" style={{ color: 'var(--muted-foreground)' }}>
            Elegí las columnas a incluir. Se exportan los <span className="font-bold" style={{ color: 'var(--foreground)' }}>{rows.length}</span> {rows.length === 1 ? 'registro' : 'registros'} del filtro actual.
          </p>
        </div>
        <div className="px-6 py-4 overflow-y-auto" style={{ flex: '1 1 auto' }}>
          <button type="button" onClick={toggleAll}
            className="flex items-center gap-2.5 mb-3 text-[11px] font-bold uppercase tracking-[0.1em] cursor-pointer transition-colors" style={{ color: 'var(--accent)' }}>
            <span className="inline-flex items-center justify-center rounded-md flex-shrink-0"
              style={{ width: 18, height: 18, border: '1.5px solid ' + (allSel ? 'var(--accent)' : 'color-mix(in oklab, var(--border) 80%, transparent)'), background: allSel ? 'var(--accent)' : 'transparent', color: 'var(--accent-foreground)' }}>
              {allSel && <IconCheck size={12} />}
            </span>
            {allSel ? 'Quitar todo' : 'Seleccionar todo'}
          </button>
          <div className="space-y-1.5">
            {exportConfig.map((c, i) => {
              const checked = sel.includes(i);
              return (
                <button key={i} type="button" onClick={() => toggle(i)}
                  className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors cursor-pointer"
                  style={{ background: checked ? 'color-mix(in oklab, var(--accent) 8%, transparent)' : 'transparent', border: '1px solid color-mix(in oklab, var(--border) 45%, transparent)' }}>
                  <span className="inline-flex items-center justify-center rounded-md flex-shrink-0"
                    style={{ width: 20, height: 20, border: '1.5px solid ' + (checked ? 'var(--accent)' : 'color-mix(in oklab, var(--border) 80%, transparent)'), background: checked ? 'var(--accent)' : 'transparent', color: 'var(--accent-foreground)' }}>
                    {checked && <IconCheck size={13} />}
                  </span>
                  <span className="text-[13px] font-bold" style={{ color: 'var(--foreground)' }}>{c.label}</span>
                </button>
              );
            })}
          </div>
        </div>
        <div className="px-6 pb-6 pt-3 flex items-center justify-end gap-2 flex-shrink-0"
          style={{ borderTop: '1px solid color-mix(in oklab, var(--border) 55%, transparent)' }}>
          <button onClick={onClose} className="btn btn-outline">Cancelar</button>
          <button onClick={doExport} className="btn btn-primary" disabled={sel.length === 0} aria-disabled={sel.length === 0}>
            <IconDownload size={14} />Exportar Excel
          </button>
        </div>
      </div>
    </div>
  );
}

// ─── Reusable: agregados server-side (GET /api/<coleccion>/stats) ─────────
// Hook liviano compartido por los callers de EntityListScreen (Presupuestos, OC,
// Remitos, Compras, Entidades, Médicos, Particulares, Proveedores…) y por
// FinanzasScreen (screens-finanzas.jsx, P3), que la llama con una `key`
// DINÁMICA (cambia al cambiar de lado/docType, y puede pasar a `null` en los
// docTypes que quedan en modo memoria). Mismo patrón defensivo que el piloto
// de Expedientes (screens-list.jsx `srvStats`): pide (MicosStore.getStats
// cachea por colección), y si falla —o la colección todavía no tiene vista de
// /stats— devuelve `null` y el caller sigue con su cálculo en memoria de
// siempre (fallback), sin romper la pantalla. RESETEA a `null` en cada cambio
// de `key` (incluido null): si no, un caller con key dinámica mostraría el
// agregado STALE de la colección ANTERIOR mientras llega (o si nunca llega,
// porque la nueva key es null) el de la nueva.
function useEntityStats(key) {
  const [stats, setStats] = _useStateEL(null);
  React.useEffect(() => {
    setStats(null);
    if (!key || !window.MicosStore) return undefined;
    let cancelled = false;
    window.MicosStore.getStats(key)
      .then((data) => { if (!cancelled) setStats(data); })
      .catch((e) => console.warn('[useEntityStats]', key, e.message));
    return () => { cancelled = true; };
  }, [key]);
  return stats;
}

function EntityListScreen({
  eyebrow,
  title,
  description,
  primaryAction,     // { label, icon, onClick }
  secondaryAction,   // { label, icon, onClick }
  kpis = [],         // [{ label, value, delta, deltaTone, icon }]
  topChips,          // { options: [...], applyFilter, allLabel? } — chips rendered ABOVE the KPIs
  tabs,              // { options:[{key,label,title,description,kpis,columns?}], applyFilter:(row,key)=>bool, default? } — segmentado primario estilo Finanzas (Cobros/Pagos): el h1 ES el tab activo y cambia con animación; sin "Todos". `columns` por tab sobreescribe las columnas de la tabla para ese lado.
  filterChips,       // { options: [{ label, value, count, tone? }], value, onChange, allLabel? }
  columns = [],      // [{ key, label, align, width, render, mono }]
  rows = [],
  collection,        // string — key del SCHEMA (window.MicosStore.SCHEMA), ej. 'presupuestos'.
                      // Si se pasa, el motor pagina SERVER-SIDE (patrón Expedientes/
                      // screens-list.jsx: fetchPage + IntersectionObserver + total real desde
                      // /stats) en vez de renderizar `rows` completo. Si NO se pasa, se
                      // mantiene el modo anterior (compat con listas que no migraron).
  searchPlaceholder = 'Buscar…',
  searchFilter,      // (row, q) => bool
  onRowClick,
  rowChevron,        // muestra una flecha (chevron) al final de la fila clickeable, con feedback de hover
  emptyText = 'Sin resultados.',
  extraBelow, // optional ReactNode rendered after the table
  prefilter,  // { label, sublabel, predicate, onClear } — inbound filter from another module
  dateField,  // string — campo ISO de cada fila para el filtro desde–hasta (si se pasa, se muestra)
  exportConfig, // [{ label, value:(row)=>string|number }] — columnas para el Excel; default deriva de `columns`
  exportFilename, // nombre base del archivo exportado (default: title)
  highlightRowId, // id de una fila puntual a resaltar (ej. recién creada) — sin equivalente
                  // genérico antes de P6; mismo estilo que ya usaba Expedientes a mano.
  sortRows,   // (a, b) => n — orden de PRESENTACIÓN client-side sobre lo cargado (P6:
              // Expedientes ordena por fecha de inicio desc; el orden del server pagina
              // por created_at/id, que en la semilla bulk es casi aleatorio). Opcional.
}) {
  const [search, setSearch] = _useStateEL('');
  const [chipValue, setChipValue] = _useStateEL(filterChips?.value ?? null);
  const [topChipValue, setTopChipValue] = _useStateEL(topChips?.value ?? null);
  const [listToast, setListToast] = _useStateEL(null);
  const [dateFrom, setDateFrom] = _useStateEL('');
  const [dateTo, setDateTo] = _useStateEL('');
  const [tabValue, setTabValue] = _useStateEL(tabs ? (tabs.default ?? (tabs.options[0] && tabs.options[0].key)) : null);
  // Dirección de la animación del título al cambiar de tab (ltr al ir a un tab anterior, rtl a uno posterior).
  const _tabSeen = React.useRef(null);
  const _tabChanged = !!tabs && _tabSeen.current !== null && _tabSeen.current !== tabValue;
  const _tabPrevIdx = tabs ? tabs.options.findIndex((o) => o.key === _tabSeen.current) : -1;
  const _tabCurIdx = tabs ? tabs.options.findIndex((o) => o.key === tabValue) : -1;
  const _tabDir = _tabCurIdx >= _tabPrevIdx ? 'animate-name-pop-rtl' : 'animate-name-pop-ltr';
  React.useEffect(() => { _tabSeen.current = tabValue; }, [tabValue]);

  // ── Paginación SERVER-SIDE (patrón Expedientes/screens-list.jsx propagado al
  // motor) — SOLO cuando el caller pasa `collection` (key de window.MicosStore.SCHEMA).
  // Sin esa prop el motor sigue en modo anterior (renderiza `rows` completo tal cual
  // lo pasó el caller — compat con listas que no migraron, ej. Logística/Caja Chica/
  // Aprobaciones/Ajustes, acopladas a lógica que necesita el array entero en memoria).
  // Estado PROPIO de esta pantalla: no toca hydrate/BIG_LIMIT/recomputeDerived ni los
  // arrays globales de window (solo los LEE/mergea puntualmente en el drill-down, más
  // abajo). Guard sincrónico por ref (`pgLoadingRef`) para no disparar un fetch
  // duplicado mientras el anterior sigue en vuelo (el observer cierra sobre un state
  // viejo hasta que el efecto se recree).
  const [pgRows, setPgRows] = _useStateEL([]);
  const [pgTotal, setPgTotal] = _useStateEL(0);
  const [pgLoading, setPgLoading] = _useStateEL(false);
  const [exportUniverseLoading, setExportUniverseLoading] = _useStateEL(false);
  const pgOffsetRef = React.useRef(0);
  const pgLoadingRef = React.useRef(false);
  const pgSentinelRef = React.useRef(null);
  const PG_BATCH = (window.LIST_CONFIG && window.LIST_CONFIG.batchSize) || 60;
  const _schemaCfg = (collection && window.MicosStore && window.MicosStore.SCHEMA) ? window.MicosStore.SCHEMA[collection] : null;

  // Carga de la primera tanda al montar / si cambia la colección. Fallback: si el
  // fetch falla, usar `rows` (lo que ya haya hidratado hydrate() en memoria —
  // semilla o capado a BIG_LIMIT) para no dejar la pantalla vacía.
  React.useEffect(() => {
    if (!collection) return undefined;
    let cancelled = false;
    setPgRows([]);
    setPgTotal(0);
    pgOffsetRef.current = 0;
    setPgLoading(true);
    window.MicosStore.fetchPage(collection, { offset: 0, limit: PG_BATCH })
      .then((r) => {
        if (cancelled) return;
        const items = r.items || [];
        setPgRows(items);
        setPgTotal(r.total || 0);
        pgOffsetRef.current = items.length;
      })
      .catch((e) => {
        console.warn('[EntityListScreen] fetchPage', collection, e.message);
        if (cancelled) return;
        setPgRows(rows.slice());
        setPgTotal(rows.length);
        pgOffsetRef.current = rows.length;
      })
      .finally(() => { if (!cancelled) setPgLoading(false); });
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [collection]);

  // Sentinel + IntersectionObserver (patrón EXACTO de screens-inventario.jsx /
  // screens-list.jsx): al entrar en viewport, pide la próxima tanda y appendea.
  const pgHayMas = collection ? pgRows.length < pgTotal : false;
  React.useEffect(() => {
    if (!collection || !pgHayMas) return undefined;
    const el = pgSentinelRef.current;
    if (!el || typeof IntersectionObserver === 'undefined') return undefined;
    const obs = new IntersectionObserver((entries) => {
      if (!(entries[0] && entries[0].isIntersecting) || pgLoadingRef.current) return;
      pgLoadingRef.current = true;
      setPgLoading(true);
      window.MicosStore.fetchPage(collection, { offset: pgOffsetRef.current, limit: PG_BATCH })
        .then((r) => {
          const items = r.items || [];
          setPgRows((prev) => prev.concat(items));
          pgOffsetRef.current += items.length;
          if (typeof r.total === 'number') setPgTotal(r.total);
        })
        .catch((e) => console.warn('[EntityListScreen] fetchPage (más)', collection, e.message))
        .finally(() => { pgLoadingRef.current = false; setPgLoading(false); });
    }, { rootMargin: (window.LIST_CONFIG && window.LIST_CONFIG.observerRootMargin) || '400px' });
    obs.observe(el);
    return () => obs.disconnect();
  }, [collection, pgHayMas]);

  // Fuente efectiva de filas: paginada server-side si hay `collection`; si no, la
  // prop `rows` de siempre (modo anterior, sin migrar).
  const _sourceRows = collection ? pgRows : rows;

  // ── Drill-down genérico (patrón openExpediente de screens-list.jsx / applyPath
  // de app.jsx): al abrir una fila, si el registro NO está en window[coll] (puede
  // venir de una página 2+ que el store global nunca hidrató), lo trae por
  // publicId/numero/id y lo mergea ANTES de abrir el detalle — así el detalle
  // (que lee window[coll] síncrono) nunca se queda con datos incompletos.
  const _openRow = (row) => {
    if (!onRowClick) return;
    if (!collection || !_schemaCfg) { onRowClick(row); return; }
    const globalArr = window[_schemaCfg.global];
    if (Array.isArray(globalArr) && globalArr.some((x) => x.id === row.id)) {
      onRowClick(row);
      return;
    }
    const ident = row.publicId || row.numero || row.id;
    fetch(`${_schemaCfg.endpoint}/${encodeURIComponent(ident)}`, { credentials: 'same-origin' })
      .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
      .then((server) => {
        if (!server || !server.id) throw new Error('sin id');
        const coll = window[_schemaCfg.global] || (window[_schemaCfg.global] = []);
        if (!coll.some((x) => x.id === server.id)) coll.push(server);
        onRowClick(server);
      })
      .catch((err) => {
        console.warn('[EntityListScreen] drill-down fetch', collection, err);
        onRowClick(row); // best-effort: abre igual con lo que ya tenía
      });
  };

  // ── Exportar: columnas para el Excel (default = todas las columnas con label) ──
  const effExportConfig = (exportConfig && exportConfig.length
    ? exportConfig
    : columns.filter((c) => c.label).map((c) => ({ label: c.label, value: (row) => row[c.key] })));
  const [exportOpen, setExportOpen] = _useStateEL(false);

  // ── Abrir el modal de exportar en modo `collection`: traer el UNIVERSO completo del
  // filtro antes, no solo las tandas ya cargadas (hallazgo #21, AUDITORIA-ui-decorativa —
  // el modal decía "Se exportan los N registros del filtro actual" pero N era
  // `pgRows.length`, capado a lo que el scroll infinito hubiera cargado hasta ese momento,
  // arranca en 60). Si todavía falta universo por traer, lo pagina completo acá mismo
  // llamando repetido a `fetchPage` (misma API que ya usa el scroll — sin tocar su firma).
  // Bloquea el observer (`pgLoadingRef`) mientras dura para no pisarse con un scroll
  // concurrente, y deja `pgRows`/`pgOffsetRef` consistentes al terminar (el scroll infinito
  // sigue andando bien después: `pgHayMas` da false y no vuelve a pedir nada de más). Efecto
  // colateral aceptado: como `filtered`/la tabla leen el mismo `pgRows`, después de exportar
  // la pantalla también queda con el universo completo cargado (ya no hace falta scrollear
  // para que búsqueda/chips vean todo).
  const openExportModal = () => {
    if (!collection || pgRows.length >= pgTotal) { setExportOpen(true); return; }
    pgLoadingRef.current = true;
    setExportUniverseLoading(true);
    const traerResto = (acc, total) => {
      if (acc.length >= total) return Promise.resolve(acc);
      return window.MicosStore.fetchPage(collection, { offset: acc.length, limit: PG_BATCH })
        .then((r) => {
          const items = r.items || [];
          if (!items.length) return acc; // el server no tiene más — corta acá, no vuelve a pedir
          const nuevoTotal = typeof r.total === 'number' ? r.total : total;
          return traerResto(acc.concat(items), nuevoTotal);
        });
    };
    traerResto(pgRows.slice(), pgTotal)
      .then((todas) => {
        setPgRows(todas);
        pgOffsetRef.current = todas.length;
        setPgTotal((t) => Math.max(t, todas.length));
      })
      .catch((e) => {
        // Best-effort (mismo criterio que el drill-down de más abajo): si falla a mitad de
        // camino, abre igual con lo que ya se había juntado — nunca deja el botón trabado.
        console.warn('[EntityListScreen] export: no se pudo traer el universo completo', collection, e.message);
      })
      .finally(() => {
        pgLoadingRef.current = false;
        setExportUniverseLoading(false);
        setExportOpen(true);
      });
  };

  // Apply prefilter (from cross-module navigation) first, then top chip filter, then chip filter, then search.
  // LIMITACIÓN (modo `collection`, igual que Expedientes): estos filtros operan
  // CLIENT-SIDE sobre `_sourceRows` = `pgRows` (lo ya cargado por el scroll infinito
  // server-side), no sobre el universo completo. Si el registro buscado todavía no
  // se cargó, no aparece hasta scrollear más. KPIs/chips no tienen este problema
  // cuando el caller los conecta a /stats (paso siguiente, fuera del motor).
  const filtered = _useMemoEL(() => {
    let r = _sourceRows;
    if (prefilter?.predicate) {
      r = r.filter(prefilter.predicate);
    }
    if (tabs && tabs.applyFilter && tabValue != null) {
      r = r.filter((row) => tabs.applyFilter(row, tabValue));
    }
    if (topChips && topChipValue != null && topChips.applyFilter) {
      r = r.filter((row) => topChips.applyFilter(row, topChipValue));
    }
    if (filterChips && chipValue != null && filterChips.applyFilter) {
      r = r.filter((row) => filterChips.applyFilter(row, chipValue));
    }
    if (search && searchFilter) {
      const q = search.toLowerCase();
      r = r.filter((row) => searchFilter(row, q));
    }
    r = applyDateRange(r, dateField, dateFrom, dateTo);
    if (sortRows) r = [...r].sort(sortRows);
    return r;
  }, [search, chipValue, topChipValue, tabValue, _sourceRows, prefilter, dateFrom, dateTo, dateField, sortRows]);

  // Hallazgo auditoría 05/07/2026 ("Contador 'N resultados' junto al buscador"): en modo
  // `collection`, el contador de la barra superior mostraba SIEMPRE `pgTotal` (el universo
  // COMPLETO sin filtro, vía X-Total-Count) — con un filtro/búsqueda/chip/rango de fechas
  // activo, `filtered` (lo que la tabla realmente pinta) podía ser un subconjunto MUCHO más
  // chico y el número mostrado no reflejaba nada real. `pgTotal` sigue siendo el correcto
  // SOLO cuando no hay ningún filtro activo (ahí sí es preferible al `filtered.length` de lo
  // ya cargado, ver comentario de más abajo). Tabs NO cuenta como filtro acá a propósito
  // (documentado como navegación, no filtro — y hoy solo lo usan listas sin `collection`).
  const hayFiltroDeListaActivo = !!(
    (search && searchFilter) ||
    (filterChips && chipValue != null) ||
    (topChips && topChipValue != null) ||
    dateFrom ||
    dateTo ||
    prefilter?.predicate
  );

  const isExportAction = secondaryAction && secondaryAction.label === 'Exportar' && !secondaryAction.onClick;

  // ── Tabs (segmentado primario estilo Finanzas) ──
  const _activeTab = tabs ? (tabs.options.find((o) => o.key === tabValue) || tabs.options[0]) : null;
  const _effTitle = _activeTab ? (_activeTab.title || _activeTab.label) : title;
  const _effDescription = _activeTab && _activeTab.description != null ? _activeTab.description : description;
  const _effKpis = _activeTab && _activeTab.kpis ? _activeTab.kpis : kpis;
  // Filas de la pestaña activa (antes de chips/búsqueda) → base para los conteos por lado.
  // (Nota: `tabs` hoy solo lo usan listas que NO pasan `collection` — ej. Logística —
  // así que en modo paginado `_sourceRows` = `rows` de todos modos.)
  const _tabRows = (tabs && tabs.applyFilter && tabValue != null) ? _sourceRows.filter((r) => tabs.applyFilter(r, tabValue)) : _sourceRows;
  const _chipBase = tabs ? _tabRows : _sourceRows;
  // Columnas efectivas: si el tab activo define `columns`, se usan en lugar de las globales.
  const _cols = (_activeTab && _activeTab.columns) ? _activeTab.columns : columns;

  const gridTemplate = _cols.map(c => c.width || '1fr').join(' ');
  const minWidth = _cols.reduce((sum, c) => {
    const w = c.width || '1fr';
    const m = /(\d+)px/.exec(w);
    return sum + (m ? parseInt(m[1], 10) : 140);
  }, 0);

  return (
    <div className="space-y-7">
      {/* ── Header ───────────────────────────────── */}
      <div className="flex flex-wrap items-end justify-between gap-4 animate-fade-in-up pt-2">
        <div className="space-y-3 min-w-0">
          {eyebrow && (
            <p className="text-[11px] font-bold uppercase tracking-[0.18em]" style={{ color: 'var(--accent)' }}>
              {eyebrow}
            </p>
          )}
          <h1 className="text-[clamp(28px,4.2vw,40px)] leading-[1.05] font-extrabold tracking-[-0.02em]" style={{ color: 'var(--foreground)' }}>
            {tabs ? (
              <span key={tabValue} className={`inline-block ${_tabChanged ? _tabDir : ''}`}>{_effTitle}</span>
            ) : title}
          </h1>
          {_effDescription && (
            <p className="text-[13.5px] font-medium max-w-2xl" style={{ color: 'var(--muted-foreground)' }}>
              {_effDescription}
            </p>
          )}
        </div>
        {(primaryAction || secondaryAction) && (
          <div className="flex items-center gap-2.5 flex-shrink-0">
            {secondaryAction && (
              <button
                onClick={isExportAction ? openExportModal : secondaryAction.onClick}
                className="btn btn-outline"
                disabled={isExportAction && exportUniverseLoading}
                aria-disabled={isExportAction && exportUniverseLoading}
              >
                {secondaryAction.icon && <secondaryAction.icon size={15} />}
                <span className="hidden sm:inline">{isExportAction && exportUniverseLoading ? 'Preparando…' : secondaryAction.label}</span>
              </button>
            )}
            {primaryAction && (
              <button onClick={primaryAction.onClick} className="btn btn-primary">
                {primaryAction.icon && <primaryAction.icon size={15} />}
                <span className="hidden sm:inline">{primaryAction.label}</span>
              </button>
            )}
          </div>
        )}
      </div>

      {/* ── Tabs (segmentado primario estilo Finanzas: Ingresos/Egresos) ── */}
      {tabs && (
        <div className="flex items-center justify-start gap-2 animate-fade-in">
          {tabs.options.map((t) => {
            const active = tabValue === t.key;
            return (
              <button key={t.key} onClick={() => setTabValue(t.key)}
                className="chip flex-shrink-0"
                style={active
                  ? { background: 'var(--accent)', color: 'var(--accent-foreground)', boxShadow: '0 6px 18px -8px rgba(15,187,182,0.55)' }
                  : { background: 'color-mix(in oklab, var(--muted-foreground) 8%, transparent)', color: 'var(--muted-foreground)' }}>
                {t.label}
              </button>
            );
          })}
        </div>
      )}

      {/* ── Top chips (above KPIs) ──────────────── */}
      {topChips && (
        <div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none animate-fade-in-up" style={{ animationDelay: '120ms' }}>
          <button
            onClick={() => setTopChipValue(null)}
            className="chip"
            style={topChipValue == null
              ? { background: 'var(--accent)', color: 'var(--accent-foreground)', boxShadow: '0 6px 18px -8px rgba(15,187,182,0.55)' }
              : { background: 'color-mix(in oklab, var(--muted-foreground) 8%, transparent)', color: 'var(--muted-foreground)' }
            }
          >
            {topChips.allLabel || 'Todos'}
            <span className="opacity-70 tabular-nums">{_sourceRows.length}</span>
          </button>
          {topChips.options.map((opt) => {
            const active = topChipValue === opt.value;
            return (
              <button
                key={opt.value}
                onClick={() => setTopChipValue(active ? null : opt.value)}
                className={active ? `chip ${opt.pill || ''}` : 'chip'}
                style={active
                  ? { background: 'var(--accent)', color: 'var(--accent-foreground)', boxShadow: '0 6px 18px -8px rgba(15,187,182,0.55)' }
                  : { background: 'color-mix(in oklab, var(--muted-foreground) 8%, transparent)', color: 'var(--muted-foreground)' }
                }
              >
                {opt.label}
                {opt.count != null && <span className="opacity-70 tabular-nums">{opt.count}</span>}
              </button>
            );
          })}
        </div>
      )}

      {/* ── KPIs ─────────────────────────────────── */}
      {_effKpis.length > 0 && (
        <div className={`grid grid-cols-2 ${_effKpis.length >= 4 ? 'lg:grid-cols-4' : `lg:grid-cols-${_effKpis.length}`} gap-3 sm:gap-4 stagger`}>
          {_effKpis.map((k, i) => (
            <KpiCard
              key={i}
              label={k.label}
              value={k.value}
              deltaTone={k.deltaTone}
              icon={k.icon}
              trend={k.trend}
            />
          ))}
        </div>
      )}

      {/* ── Filter chips ─────────────────────────── */}
      {filterChips && (
        <div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none animate-fade-in-up" style={{ animationDelay: '180ms' }}>
          <button
            onClick={() => setChipValue(null)}
            className="chip"
            style={chipValue == null
              ? { background: 'var(--accent)', color: 'var(--accent-foreground)', boxShadow: '0 6px 18px -8px rgba(15,187,182,0.55)' }
              : { background: 'color-mix(in oklab, var(--muted-foreground) 8%, transparent)', color: 'var(--muted-foreground)' }
            }
          >
            {filterChips.allLabel || 'Todos'}
            {/* En modo `collection` mostramos el TOTAL real (pgTotal, vía /stats o
                X-Total-Count), no `_chipBase.length` (solo lo ya cargado) — mismo
                criterio que el pie de la tabla, más abajo. */}
            <span className="opacity-70 tabular-nums">{collection ? pgTotal : _chipBase.length}</span>
          </button>
          {filterChips.options.map((opt) => {
            const active = chipValue === opt.value;
            return (
              <button
                key={opt.value}
                onClick={() => setChipValue(active ? null : opt.value)}
                className={active ? `chip ${opt.pill || ''}` : 'chip'}
                style={active
                  ? { background: 'var(--accent)', color: 'var(--accent-foreground)', boxShadow: '0 6px 18px -8px rgba(15,187,182,0.55)' }
                  : { background: 'color-mix(in oklab, var(--muted-foreground) 8%, transparent)', color: 'var(--muted-foreground)' }
                }
              >
                {opt.label}
                {(() => { const c = (tabs && filterChips.applyFilter) ? _chipBase.filter((r) => filterChips.applyFilter(r, opt.value)).length : opt.count; return c != null ? <span className="opacity-70 tabular-nums">{c}</span> : null; })()}
              </button>
            );
          })}
        </div>
      )}

      {/* ── Prefilter banner (cross-module filter context) ── */}
      {prefilter && (
        <div
          className="flex items-center gap-3 rounded-xl px-4 sm:px-5 py-3 animate-fade-in-up"
          style={{
            background: 'color-mix(in oklab, var(--accent) 8%, var(--card))',
            border: '1px solid color-mix(in oklab, var(--accent) 40%, transparent)',
            animationDelay: '120ms',
          }}
        >
          <span className="inline-flex items-center justify-center rounded-lg flex-shrink-0"
            style={{
              width: 30, height: 30,
              background: 'color-mix(in oklab, var(--accent) 16%, transparent)',
              color: 'var(--accent)',
            }}>
            <IconFilter size={14} />
          </span>
          <div className="min-w-0 flex-1">
            <p className="text-[10.5px] font-bold uppercase tracking-[0.16em]" style={{ color: 'var(--accent)' }}>
              Filtrado desde otro módulo
            </p>
            <p className="text-[13px] font-bold truncate mt-0.5" style={{ color: 'var(--foreground)' }}>
              {prefilter.label}
              {prefilter.sublabel && (
                <span className="ml-2 font-medium" style={{ color: 'var(--muted-foreground)' }}>· {prefilter.sublabel}</span>
              )}
            </p>
          </div>
          {prefilter.onClear && (
            <button
              onClick={prefilter.onClear}
              type="button"
              className="inline-flex items-center gap-1.5 text-[11.5px] font-bold uppercase tracking-[0.1em] px-3 py-1.5 rounded-md transition-colors cursor-pointer"
              style={{ color: 'var(--accent)', background: 'transparent', border: '1px solid color-mix(in oklab, var(--accent) 40%, transparent)' }}
              onMouseEnter={(e) => e.currentTarget.style.background = 'color-mix(in oklab, var(--accent) 12%, transparent)'}
              onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
            >
              Quitar filtro
              <IconX size={12} />
            </button>
          )}
        </div>
      )}

      {/* ── Table card (search header + table) ──── */}
      <div className="glow-track animate-fade-in-up rounded-2xl" style={{
        animationDelay: '220ms',
        background: 'var(--card)',
        border: '1px solid color-mix(in oklab, var(--border) 55%, transparent)',
        boxShadow: 'var(--shadow-card)',
        overflow: 'hidden',
      }}>
        {/* Search row */}
        <div className="flex items-center justify-between gap-4 px-5 py-4 flex-wrap"
          style={{ borderBottom: '1px solid color-mix(in oklab, var(--border) 55%, transparent)' }}>
          <div className="relative flex-1 min-w-[200px] max-w-2xl">
            <IconSearch size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--muted-foreground)' }} />
            <input
              value={search}
              onChange={(e) => setSearch(e.target.value)}
              placeholder={searchPlaceholder}
              className="w-full h-10 pl-10 pr-3 text-[13px] font-medium rounded-lg outline-none transition-colors"
              style={{
                background: 'color-mix(in oklab, var(--surface) 50%, transparent)',
                border: '1px solid color-mix(in oklab, var(--border) 45%, transparent)',
                color: 'var(--foreground)',
              }}
            />
          </div>
          <div className="flex items-center gap-3 flex-wrap">
            {dateField && (
              <TableDateRange from={dateFrom} to={dateTo} onFrom={setDateFrom} onTo={setDateTo} />
            )}
            <span className="text-[12px] font-semibold tabular-nums whitespace-nowrap" style={{ color: 'var(--muted-foreground)' }}>
              {/* Modo `collection` SIN filtro activo: total REAL del universo (X-Total-Count
                  vía /stats), no lo cargado — así nunca vuelve a mostrar "60" habiendo miles.
                  Modo `collection` CON filtro activo (hallazgo auditoría 05/07/2026): el
                  universo completo ya no es el número correcto — mostramos `filtered.length`
                  como CUALQUIER otra lista (honesto sobre lo ya cargado, mismo criterio que el
                  modo sin `collection`, que siempre usó `filtered.length`). */}
              {collection && !hayFiltroDeListaActivo ? pgTotal : filtered.length} resultados
            </span>
          </div>
        </div>

        {/* Table */}
        <div style={{ overflowX: 'auto' }}>
          <div style={{ minWidth: `${minWidth}px` }}>
            {/* Head */}
            <div className="grid gap-4 px-6 py-3.5 text-[10.5px] font-bold uppercase tracking-[0.08em]"
              style={{
                gridTemplateColumns: gridTemplate,
                color: 'var(--muted-foreground)',
                borderBottom: '1px solid color-mix(in oklab, var(--border) 60%, transparent)',
              }}>
              {_cols.map((c, ci) => (
                <div key={c.key} className={`${c.align === 'right' ? 'text-right' : c.align === 'center' ? 'text-center' : ''} ${rowChevron && ci === _cols.length - 1 ? 'pr-[23px]' : ''}`}>
                  {c.label}
                </div>
              ))}
            </div>

            {/* Rows */}
            <div className="stagger-rows">
              {filtered.map((row, idx) => {
                const clickable = !!onRowClick;
                const Wrap = clickable ? 'button' : 'div';
                const isHighlighted = highlightRowId != null && row.id === highlightRowId;
                return (
                  <div
                    key={row.id || idx}
                    className="relative group/row"
                    style={{ borderBottom: idx < filtered.length - 1 ? '1px solid color-mix(in oklab, var(--border) 45%, transparent)' : 'none' }}
                  >
                  <Wrap
                    onClick={clickable ? () => _openRow(row) : undefined}
                    className={`exp-row w-full grid gap-4 px-6 py-4 items-center text-left ${clickable ? 'cursor-pointer' : ''}`}
                    style={{
                      gridTemplateColumns: gridTemplate,
                      background: isHighlighted ? 'color-mix(in oklab, var(--accent) 12%, transparent)' : undefined,
                      boxShadow: isHighlighted ? 'inset 3px 0 0 var(--accent)' : undefined,
                      transition: 'background 0.5s ease, box-shadow 0.5s ease',
                    }}
                  >
                    {_cols.map((c, ci) => {
                      const cellInner = c.render ? c.render(row) : (
                        <span className="text-[13px] font-medium truncate block" style={{ color: 'var(--foreground)' }}>
                          {row[c.key]}
                        </span>
                      );
                      const isLast = ci === _cols.length - 1;
                      return (
                      <div
                        key={c.key}
                        className={`min-w-0 ${c.align === 'right' ? 'text-right' : c.align === 'center' ? 'text-center' : ''} ${c.mono ? 'font-mono tabular-nums' : ''}`}
                      >
                        {isLast && rowChevron && clickable ? (
                          <div className="flex items-center justify-between gap-2">
                            {cellInner}
                            <span className="exp-chev" style={{ color: 'var(--muted-foreground)' }}>
                              <IconChevronRight size={15} />
                            </span>
                          </div>
                        ) : cellInner}
                      </div>
                      );
                    })}
                  </Wrap>
                  </div>
                );
              })}
              {/* Sentinel: al entrar en viewport (cerca del fondo) carga la próxima tanda */}
              {collection && pgHayMas && (
                <div ref={pgSentinelRef} className="px-6 py-6 text-center text-[12px] font-medium" style={{ color: 'var(--muted-foreground)' }}>
                  Cargando más… <span className="tabular-nums opacity-70">({pgRows.length} de {pgTotal})</span>
                </div>
              )}
              {/* Cierre: llegaste al final */}
              {collection && !pgHayMas && pgRows.length > 0 && (
                <div className="px-6 py-8 text-center text-[11px] font-bold uppercase tracking-[0.14em]" style={{ color: 'var(--muted-foreground)' }}>
                  — No hay más resultados —
                </div>
              )}
              {filtered.length === 0 && (
                <div className="px-6 py-20 text-center">
                  <p className="text-[14px] font-semibold" style={{ color: 'var(--muted-foreground)' }}>
                    {collection && pgLoading && pgRows.length === 0 ? 'Cargando…' : emptyText}
                  </p>
                </div>
              )}
            </div>
          </div>
        </div>
      </div>

      {/* ── Toast ─────────────────────────────────── */}
      {listToast && (
        <div
          className="fixed bottom-6 right-6 z-50 flex items-center gap-3 rounded-xl px-4 py-3 animate-fade-in-up"
          style={{
            background: 'var(--card)',
            border: '1px solid color-mix(in oklab, var(--accent) 45%, transparent)',
            boxShadow: '0 18px 50px -14px rgba(8, 20, 24, 0.5)',
            maxWidth: 'min(92vw, 380px)',
          }}
        >
          <span className="inline-flex items-center justify-center rounded-lg flex-shrink-0"
            style={{ width: 30, height: 30, background: 'color-mix(in oklab, var(--accent) 16%, transparent)', color: 'var(--accent)' }}>
            <IconCheck size={16} />
          </span>
          <p className="text-[12.5px] font-bold truncate" style={{ color: 'var(--foreground)' }}>
            {listToast}
          </p>
        </div>
      )}
      {/* ── Modal: exportar a Excel (checklist de columnas) ── */}
      <ExportModal
        open={exportOpen}
        onClose={() => setExportOpen(false)}
        exportConfig={effExportConfig}
        rows={filtered}
        filename={exportFilename || title}
        onExported={(n, res) => {
          setListToast(`Exportado · ${n} ${n === 1 ? 'fila' : 'filas'}${res && res.format === 'csv' ? ' (CSV)' : ''}`);
          setTimeout(() => setListToast(null), 3600);
        }}
      />
      {extraBelow}
    </div>
  );
}

Object.assign(window, { EntityListScreen, TableDateRange, ExportModal, applyDateRange, useEntityStats });
