// ─── Inventario · Lista ──────────────────────────────────────────────────
// InventarioScreen: lista con dos dominios excluyentes (Consumibles/
// Alquilables), paginación server-side propia (fetchPage+IntersectionObserver,
// NO usa EntityListScreen — catálogo de miles de filas, motivo documentado
// abajo), KPIs/chips de vencimiento con fallback srv→memoria. Extraído (P10)
// de screens-inventario.jsx (993 líneas) junto con detalle.jsx e historial.jsx;
// invLoteEstado/INV_LOTE_CFG/invTiempoRestante/invProxVencimiento/
// invHasVencido/INV_ESTADO_CFG/INV_CATEGORIAS se movieron a compartido.jsx
// (los usa también detalle.jsx). Expone a window: InventarioScreen. Consume
// window.INVENTARIO (core/colecciones.js), MicosStore.fetchPage/getStats
// (core/store.js), MicosNow/MicosTodayISO/formatMoney/formatMoneyShort/
// formatDate (dominio/formato.js), MS_PER_DAY/INV_VENC_THRESHOLDS/
// INV_STOCK_LOW_FACTOR/LIST_CONFIG (core/bundle-config.js), invLoteEstado/
// invHasVencido/INV_CATEGORIAS (compartido.jsx), KpiCard/IdDateCell (ui/
// primitives.jsx), TableDateRange/ExportModal (ui/entity-list.jsx).
//
// EXCEPCIÓN DE TECHO (muy por encima del techo duro ~400, ver tabla de
// ESTRUCTURA.md para el número real — FIX
// P13 ítem 16, Review P10 BAJA-5: el rationale de paginación propia arriba
// estaba documentado pero faltaba la excepción de techo como tal): un solo
// tema (la lista de Inventario, catálogo de miles de filas) que NO puede
// reusar EntityListScreen (paginación infinita propia + dos dominios
// excluyentes Consumibles/Alquilables con columnas/KPIs/chips distintos cada
// uno) — partirlo por dominio metería 2 archivos casi idénticos con el mismo
// motor de paginación duplicado, peor que un archivo grande y cohesivo. No
// amerita re-split (arbitraje de la review P10).

const { useState: _useStateInv, useMemo: _useMemoInv } = React;

// ─── InventarioScreen ─────────────────────────────────

function InventarioScreen({ onOpen, onNew, onOpenActivo, onNewActivo, onVerHistorialAlquileres }) {
  const [search, setSearch] = _useStateInv('');
  const [dateFrom, setDateFrom] = _useStateInv('');
  const [dateTo, setDateTo] = _useStateInv('');
  const [exportOpen, setExportOpen] = _useStateInv(false);
  const [categoria, setCategoria] = _useStateInv(null);    // null = todas
  const [vencimiento, setVencimiento] = _useStateInv(null); // null = todos
  // Dominio (nivel 1, estilo Cobros/Pagos de Finanzas): consumibles vs alquilables.
  const [dominio, setDominio] = _useStateInv('consumibles'); // consumibles | alquilables
  const [estadoAlq, setEstadoAlq] = _useStateInv(null);      // chip de estado (alquilables)
  const ALQ_CAT = 'Equipos VAC';
  const switchDominio = (d) => { setDominio(d); setCategoria(null); setVencimiento(null); setEstadoAlq(null); };
  const isAlq = dominio === 'alquilables';
  // INVENTARIO local SOMBREA al global, filtrado por dominio: todas las derivaciones de abajo
  // (KPIs, tabs, chips, filtered) operan ya sobre el dominio activo sin tocarlas una a una.
  const INVENTARIO = (window.INVENTARIO || []).filter(i =>
    isAlq ? i.categoria === ALQ_CAT : i.categoria !== ALQ_CAT);
  // P23 (VACs): el dominio Alquilables lista EQUIPOS FÍSICOS (inventario_activo, uno por
  // codigo_interno — 16 hoy), NO los 3 "modelos" de `inventario` (VAC-C/VAC-G/VAC-M) que
  // usaba el modelo viejo. FIX del bug reportado por Fran ("Todos/Estacionados/Alquilados
  // muestran lo mismo"): antes esto era `INVENTARIO.flatMap(i => i.activos)` y el filtro de
  // estado (más abajo) preguntaba "¿el PRODUCTO tiene ALGÚN activo con este estado?" — con
  // solo 3 productos y 16 equipos repartidos entre ellos, casi cualquier estado hacía match
  // en los 3 productos por igual (Estacionados/Alquilados == Todos); Reparación "andaba" de
  // pura casualidad (era el único estado que un solo producto — VAC-C — tenía). Ahora se lee
  // window.INVENTARIO_ACTIVOS (API nueva, /api/inventario-activos — estado real por equipo,
  // ya viene con tamaño/último movimiento resueltos por el backend) y se filtra por el
  // estado REAL de cada fila, sin intermediar por el producto.
  const _alqActivos = window.INVENTARIO_ACTIVOS || [];
  const _alqCount = (e) => _alqActivos.filter(a => a.estado === e).length;

  // ── Scroll infinito SERVER-SIDE (B4/PokeAPI Fase 2 — BUGS-caza-01-07-2026.md #28):
  // reemplaza el slice client-side sobre `window.INVENTARIO` (hidratado SIN ?limit,
  // capado en silencio al tope de 1000 filas de PostgREST) por el MISMO patrón que
  // Expedientes/EntityListScreen: `MicosStore.fetchPage('inventario', {offset, limit})`
  // en tandas + `IntersectionObserver` + pie con el total REAL (X-Total-Count). Estado
  // PROPIO de la pantalla (no toca window.INVENTARIO/hydrate). Alimenta SOLO el
  // dominio Consumibles (ver `filtered` más abajo): Alquilables (Equipos VAC) sigue
  // leyendo `INVENTARIO` (el array completo hidratado al boot, ya filtrado a
  // `ALQ_CAT` arriba) — es un catálogo chico y acotado (3 equipos físicos hoy,
  // alquilables reales, no un listado que vaya a crecer a miles) que YA representa
  // el universo completo sin necesidad de paginar.
  const [pgRows, setPgRows] = _useStateInv([]);
  const [pgTotal, setPgTotal] = _useStateInv(0);
  const [pgLoading, setPgLoading] = _useStateInv(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;

  // Carga de la primera tanda al montar. Fallback: si el fetch falla, usar lo que ya
  // haya hidratado hydrate() en memoria (mismo patrón defensivo que Expedientes).
  React.useEffect(() => {
    let cancelled = false;
    setPgLoading(true);
    window.MicosStore.fetchPage('inventario', { 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('[InventarioScreen] fetchPage', e.message);
        if (cancelled) return;
        const fallback = (window.INVENTARIO || []).filter((i) => i.categoria !== ALQ_CAT);
        setPgRows(fallback);
        setPgTotal(fallback.length);
        pgOffsetRef.current = fallback.length;
      })
      .finally(() => { if (!cancelled) setPgLoading(false); });
    return () => { cancelled = true; };
  }, []);

  // Sentinel + IntersectionObserver (patrón EXACTO de screens-list.jsx): al entrar en
  // viewport pide la próxima tanda y appendea. Guard sincrónico por ref
  // (`pgLoadingRef`) contra fetch duplicado mientras el anterior sigue en vuelo (con
  // state el closure del observer quedaría con un valor viejo hasta que el efecto se
  // vuelva a crear).
  const pgHayMas = pgRows.length < pgTotal;
  React.useEffect(() => {
    if (!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('inventario', { 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('[InventarioScreen] fetchPage (más)', e.message))
        .finally(() => { pgLoadingRef.current = false; setPgLoading(false); });
    }, { rootMargin: (window.LIST_CONFIG && window.LIST_CONFIG.observerRootMargin) || '400px' }); // precarga antes de tocar el fondo
    obs.observe(el);
    return () => obs.disconnect();
  }, [pgHayMas]);

  // Secciones (categorías) propias que agrega el usuario — persistidas.
  const [customCats, setCustomCats] = _useStateInv(() => {
    try { return JSON.parse(localStorage.getItem('micos-inv-categorias') || '[]'); } catch (e) { return []; }
  });
  const [addCatOpen, setAddCatOpen] = _useStateInv(false);
  const [newCatName, setNewCatName] = _useStateInv('');
  const confirmAddCat = () => {
    const name = newCatName.trim();
    if (!name) { setAddCatOpen(false); setNewCatName(''); return; }
    const exists = [...INV_CATEGORIAS, ...INVENTARIO.map(i => i.categoria), ...customCats]
      .some(c => c && c.toLowerCase() === name.toLowerCase());
    if (!exists) {
      const next = [...customCats, name];
      setCustomCats(next);
      try { localStorage.setItem('micos-inv-categorias', JSON.stringify(next)); } catch (e) {}
    }
    setCategoria(name);
    setAddCatOpen(false); setNewCatName('');
  };

  // Fallback en memoria (mismo criterio de siempre, sobre `INVENTARIO` — el array
  // COMPLETO hidratado al boot, NO el `pgRows` parcial de la tabla): se usa solo
  // mientras `srvStats` no llegó o si el endpoint falla (los valores server-side
  // reemplazan a estos más abajo). Los chips de vencimiento cuentan solo productos
  // CON stock; los sin stock van a su propio chip (partición limpia: cada producto
  // cae en exactamente uno).
  const vencidosCountMem = _useMemoInv(() => INVENTARIO.filter(i => i.stock > 0 && invHasVencido(i)).length, [INVENTARIO.length, dominio]);
  const proximosCountMem = _useMemoInv(() => INVENTARIO.filter(i =>
    i.stock > 0 && !invHasVencido(i) && i.lotes.some(l => invLoteEstado(l.vencimiento) === 'PROXIMO')
  ).length, [INVENTARIO.length, dominio]);
  const vigentesCountMem = _useMemoInv(() => INVENTARIO.filter(i =>
    i.stock > 0 && !invHasVencido(i) && !i.lotes.some(l => invLoteEstado(l.vencimiento) === 'PROXIMO')
  ).length, [INVENTARIO.length, dominio]);
  const sinStockCountMem = _useMemoInv(() => INVENTARIO.filter(i => i.stock <= 0).length, [INVENTARIO.length, dominio]);
  const bajoCritico    = INVENTARIO.filter(i => ['bajo', 'critico', 'agotado'].includes(INVENTARIO_ESTADO(i))).length;
  const valorTotal     = INVENTARIO.reduce((s, i) => s + i.stock * i.costoUnitario, 0);

  // KPIs server-side (B2-piloto): GET /api/inventario/stats agrega en Postgres sobre
  // TODO el universo (el array en memoria hoy está capado a BIG_LIMIT=500). Se pide una
  // sola vez al montar (cacheado en el store); mientras no llegó o si falla, `srvStats`
  // queda null y las KPIs de abajo caen al cálculo en memoria (fallback, nunca vacío).
  const [srvStats, setSrvStats] = _useStateInv(null);
  React.useEffect(() => {
    let cancelled = false;
    window.MicosStore.getStats('inventario')
      .then((data) => { if (!cancelled) setSrvStats(data); })
      .catch((e) => { console.warn('[InventarioScreen] getStats', e.message); });
    return () => { cancelled = true; };
  }, []);

  // Divergencias documentadas de v_inventario_stats vs el cálculo en memoria (mismo
  // patrón que la nota del recon F4 — la vista no expone estas dos cosas):
  //  1) `bajoCritico` de la vista YA es la UNIÓN bajo∪crítico∪agotado (partición
  //     limpia, igual que INVENTARIO_ESTADO(it) in {bajo,critico,agotado} —
  //     migración 20260701170000_inventario_bajo_critico_union.sql, fix de
  //     BUGS-caza-01-07-2026.md #13/M6). Antes se sumaba `+ sinStock` acá para
  //     completar la unión cuando `minimo=0` (caso de hoy) — eso DUPLICABA en
  //     cuanto un producto agotado tuviera `minimo>0` (ya contado en ambos
  //     buckets server-side). Ahora la vista expone el conteo ya unido: se usa
  //     tal cual, sin sumar sinStock (que sigue disponible aparte para su propio
  //     chip "Sin stock").
  //  2) La vista agrega TODA la tabla inventario (consumibles + alquilables/"Equipos
  //     VAC"), mientras que este bloque de KPIs es solo el dominio consumibles (el
  //     dominio alquilables tiene sus propias KPIs, más abajo, que NO se tocaron). Para
  //     la KPI "Productos" SÍ importa (BUGS-caza-01-07-2026.md #29: mostraba 601 en vez
  //     de 598) — se descuenta el bucket `porCategoria[ALQ_CAT]` que la vista YA expone,
  //     en vez de sumar un filtro de dominio nuevo a la vista (que agrega otras KPIs a
  //     propósito sobre el universo completo). El resto de las KPIs de este bloque
  //     (vencidos/valorTotal/bajoCritico) no se ven afectadas hoy porque Equipos VAC no
  //     tiene lotes ni costo cargado — si eso cambia, revisar igual.
  //  3) (B4) `vencidos`/`porVencer` de la vista YA vienen domain-scoped (stock>0 +
  //     excluye ALQ_CAT — migración 20260701160000_inventario_vencidos_dominio.sql,
  //     fix #27), así que se usan tal cual para los chips de la tabla. `sinStock` de
  //     la vista agrega TODA la tabla (no excluye Equipos VAC) — hoy coincide con el
  //     valor domain-scoped porque los 3 equipos VAC siempre tienen stock>0
  //     (verificado contra DB); si algún día un equipo queda en stock 0 esto se
  //     desalinearía por 1, aceptado (mismo tipo de divergencia documentada arriba).
  //     "Vigentes" no existe como columna propia en la vista — se deriva de la
  //     partición completa (kProductos = sinStock + vencidos + porVencer + vigentes),
  //     sin agregar un query nuevo.
  const srvBajoCritico = srvStats ? (srvStats.bajoCritico || 0) : null;
  const srvVacCount = srvStats ? Number((srvStats.porCategoria || {})[ALQ_CAT] || 0) : 0;
  const kProductos = srvStats ? Number(srvStats.total || 0) - srvVacCount : INVENTARIO.length;

  const vencidosCount = srvStats ? Number(srvStats.vencidos || 0) : vencidosCountMem;
  const proximosCount = srvStats ? Number(srvStats.porVencer || 0) : proximosCountMem;
  const sinStockCount = srvStats ? Number(srvStats.sinStock || 0) : sinStockCountMem;
  const vigentesCount = srvStats
    ? Math.max(0, kProductos - sinStockCount - vencidosCount - proximosCount)
    : vigentesCountMem;

  // Categorías (tabs arriba, estilo Cobros/Pagos de Finanzas). Conteos desde
  // `srvStats.porCategoria` (universo real, B4) con fallback al array completo
  // hidratado al boot (`INVENTARIO`) mientras no llegó `/stats` o si falla — nunca
  // desde `pgRows` (parcial). El SET de categorías a mostrar (qué nombres existen)
  // sigue saliendo de la lista base + lo observado + secciones propias: son
  // etiquetas, no conteos, no necesitan el universo completo.
  const srvPorCategoria = srvStats ? (srvStats.porCategoria || {}) : null;
  const _allCats = [];
  [...INV_CATEGORIAS, ...INVENTARIO.map(i => i.categoria), ...customCats].forEach(c => {
    if (c && !_allCats.includes(c)) _allCats.push(c);
  });
  const categoriaTabs = [
    { label: 'Todas', value: null, count: isAlq ? _alqActivos.length : kProductos },
    ..._allCats.map(cat => ({
      label: cat, value: cat,
      // Alquilables: conteo por EQUIPO (_alqActivos), no por producto (INVENTARIO tiene
      // solo los 3 "modelos" VAC-C/VAC-G/VAC-M) — mismo fix de fondo que el resto del
      // dominio (ver _alqActivos más arriba).
      count: isAlq
        ? _alqActivos.filter(a => a.categoria === cat).length
        : (srvPorCategoria ? Number(srvPorCategoria[cat] || 0) : INVENTARIO.filter(i => i.categoria === cat).length),
    })).filter(o => o.count > 0 || customCats.includes(o.value)),
  ];

  // Chips de vencimiento (arriba de la tabla) — conteos server-side (derivados arriba).
  const vencimientoChips = [
    { label: 'Todos',      value: null,       count: kProductos },
    { label: 'Vencidos',   value: 'vencido',  count: vencidosCount,  tone: 'neg'  },
    { label: 'Por vencer', value: 'proximo',  count: proximosCount,  tone: 'warn' },
    { label: 'Vigentes',   value: 'vigente',  count: vigentesCount,  tone: 'pos'  },
    { label: 'Sin stock',  value: 'sin_stock', count: sinStockCount, tone: 'neutral' },
  ];
  // Chips de estado para alquilables (por disponibilidad, no por tiempo) — catálogo
  // chico, sigue leyendo del array completo (`INVENTARIO`/`_alqActivos`).
  const estadoChips = [
    { label: 'Todos',         value: null,            count: _alqActivos.length },
    { label: 'Estacionados',  value: 'ESTACIONADO',   count: _alqCount('ESTACIONADO'),   tone: 'pos'     },
    { label: 'Alquilados',    value: 'ALQUILADO',     count: _alqCount('ALQUILADO'),     tone: 'neutral' },
    { label: 'En reparación', value: 'EN_REPARACION', count: _alqCount('EN_REPARACION'), tone: 'warn'    },
  ];

  const filtered = _useMemoInv(() => {
    // Alquilables: fuente = equipos físicos (_alqActivos, window.INVENTARIO_ACTIVOS —
    // catálogo chico, 16 hoy, sin paginar). Consumibles: lo cargado server-side (pgRows,
    // filtrado defensivamente por si el fetch trae algún Equipos VAC mezclado). LIMITACIÓN
    // (mismo criterio que Expedientes/EntityListScreen): los filtros de abajo operan
    // client-side sobre lo YA cargado en Consumibles, sobre el universo completo en Alq.
    if (isAlq) {
      let r = _alqActivos;
      if (categoria != null) r = r.filter(i => i.categoria === categoria);
      // Estado REAL de CADA equipo (fix del bug de "algún activo del producto" — ver
      // comentario de _alqActivos más arriba): comparación directa, sin "some".
      if (estadoAlq) r = r.filter(a => a.estado === estadoAlq);
      if (search) {
        const q = search.toLowerCase();
        r = r.filter(a =>
          (a.codigoInterno || '').toLowerCase().includes(q) ||
          (a.numeroSerie || '').toLowerCase().includes(q) ||
          (a.tamano || '').toLowerCase().includes(q) ||
          (a.sku || '').toLowerCase().includes(q) ||
          (a.descripcion || '').toLowerCase().includes(q)
        );
      }
      if (dateFrom || dateTo) {
        r = r.filter(a => {
          const d = String((a.ultimoMovimiento && a.ultimoMovimiento.fecha) || '').slice(0, 10);
          if (!d) return false;
          if (dateFrom && d < dateFrom) return false;
          if (dateTo && d > dateTo) return false;
          return true;
        });
      }
      return r;
    }
    let r = pgRows.filter(i => i.categoria !== ALQ_CAT);
    if (categoria != null) r = r.filter(i => i.categoria === categoria);
    if (vencimiento === 'vencido') r = r.filter(i => i.stock > 0 && invHasVencido(i));
    else if (vencimiento === 'proximo') r = r.filter(i =>
      i.stock > 0 && !invHasVencido(i) && i.lotes.some(l => invLoteEstado(l.vencimiento) === 'PROXIMO')
    );
    else if (vencimiento === 'vigente') r = r.filter(i =>
      i.stock > 0 && !invHasVencido(i) && !i.lotes.some(l => invLoteEstado(l.vencimiento) === 'PROXIMO')
    );
    else if (vencimiento === 'sin_stock') r = r.filter(i => i.stock <= 0);
    if (search) {
      const q = search.toLowerCase();
      // Guardas defensivas (B4): `proveedor` NO es columna real de `inventario` (bug
      // A3/BUGS-caza-01-07-2026.md — el seed la traía, la DB no) y `categoria` puede
      // ser NULL en filas reales (misma vista de stats hace coalesce a
      // 'SIN_CATEGORIA'). Sin `|| ''` esto crasheaba el buscador con
      // `undefined.toLowerCase()` apenas se tipeaba sobre filas reales de la DB
      // (preexistente, expuesto al migrar la fuente de `INVENTARIO`/seed a
      // `pgRows`/DB real — mismo bug hubiera pegado igual ya con el hydrate de hoy).
      r = r.filter(i =>
        (i.publicId || '').toLowerCase().includes(q) ||
        (i.sku || '').toLowerCase().includes(q) ||
        (i.descripcion || '').toLowerCase().includes(q) ||
        (i.proveedor || '').toLowerCase().includes(q) ||
        (i.categoria || '').toLowerCase().includes(q)
      );
    }
    if (dateFrom || dateTo) {
      r = r.filter(i => {
        const d = String(i.ultimoIngreso || '').slice(0, 10);
        if (!d) return false;
        if (dateFrom && d < dateFrom) return false;
        if (dateTo && d > dateTo) return false;
        return true;
      });
    }
    return r;
  }, [categoria, vencimiento, search, dateFrom, dateTo, dominio, estadoAlq, pgRows, _alqActivos]);

  // Ver comentario junto al contador "N resultados" más abajo (hallazgo auditoría
  // 05/07/2026). `categoria`/`vencimiento` son filtros reales de Consumibles (no
  // navegación pura como el dominio consumibles/alquilables) — sí cuentan acá.
  const hayFiltroConsumiblesActivo = !!(search || categoria != null || vencimiento || dateFrom || dateTo);

  const invExportConfig = [
    { label: 'ID', value: (i) => i.publicId },
    { label: 'SKU', value: (i) => i.sku },
    { label: 'Descripción', value: (i) => i.descripcion },
    { label: 'Categoría', value: (i) => i.categoria },
    { label: 'Proveedor', value: (i) => i.proveedor },
    { label: 'Ubicación', value: (i) => i.ubicacion },
    { label: 'Stock', value: (i) => i.stock },
    { label: 'Stock mínimo', value: (i) => i.minimo },
    { label: 'Costo unitario', value: (i) => i.costoUnitario },
    { label: 'Valuación', value: (i) => i.valuacion },
    { label: 'Último ingreso', value: (i) => i.ultimoIngreso },
  ];

  // P23 (VACs): export config propio del dominio Alquilables — los equipos físicos no
  // tienen stock/costo/lote, tienen su propio set de columnas (ver alqColumns abajo).
  const alqExportConfig = [
    { label: 'Interno', value: (a) => a.codigoInterno },
    { label: 'Tamaño', value: (a) => a.tamano },
    { label: 'N° de serie', value: (a) => a.numeroSerie },
    { label: 'Estado', value: (a) => (VAC_ESTADO_CFG[a.estado] || {}).label || a.estado },
    { label: 'Último movimiento', value: (a) => a.ultimoMovimiento && (VAC_MOVIMIENTO_CFG[a.ultimoMovimiento.tipo] || {}).label },
    { label: 'Fecha últ. movimiento', value: (a) => a.ultimoMovimiento && a.ultimoMovimiento.fecha },
    { label: 'Paciente últ. movimiento', value: (a) => a.ultimoMovimiento && a.ultimoMovimiento.paciente },
  ];

  // P23 (VACs): columnas propias del dominio Alquilables (equipo físico, no producto) —
  // interno/tamaño/serie/estado real + último movimiento (backend ya lo resuelve, ver
  // src/lib/api/serializers/inventario-activos.ts).
  const alqColumns = [
    { key: 'codigoInterno', label: 'Interno · Tamaño', width: 'minmax(150px, 1fr)',
      render: a => (
        <div className="min-w-0">
          <p className="font-mono text-[15px] font-extrabold tracking-wide" style={{ color: 'var(--foreground)' }}>{a.codigoInterno}</p>
          <p className="text-[11.5px] mt-0.5" style={{ color: 'var(--muted-foreground)' }}>{a.tamano || 'Sin tamaño'}</p>
        </div>
      ),
    },
    { key: 'numeroSerie', label: 'N° de serie', width: 'minmax(130px, 1fr)',
      render: a => (
        <span className="font-mono text-[12px] font-semibold" style={{ color: a.numeroSerie ? 'var(--foreground)' : 'var(--muted-foreground)' }}>
          {a.numeroSerie || 'Sin serie'}
        </span>
      ),
    },
    { key: 'estado', label: 'Estado', width: '150px',
      render: a => {
        const cfg = VAC_ESTADO_CFG[a.estado] || { label: a.estado, tone: 'neutral' };
        return <TablePill label={cfg.label} tone={cfg.tone} />;
      },
    },
    { key: 'ultimoMovimiento', label: 'Último movimiento', width: 'minmax(230px, 1.6fr)',
      render: a => {
        const m = a.ultimoMovimiento;
        if (!m) return <span style={{ color: 'var(--muted-foreground)', fontSize: 12 }}>Sin movimientos</span>;
        const cfg = VAC_MOVIMIENTO_CFG[m.tipo] || { label: m.tipo, tone: 'neutral' };
        return (
          <div className="min-w-0">
            <div className="flex items-center gap-2">
              <TablePill label={cfg.label} tone={cfg.tone} />
              <span className="font-mono text-[11px] tabular-nums" style={{ color: 'var(--muted-foreground)' }}>{formatDate(m.fecha)}</span>
            </div>
            <p className="text-[11px] mt-1 truncate" style={{ color: 'var(--muted-foreground)' }}>{m.paciente || m.motivo || '—'}</p>
          </div>
        );
      },
    },
  ];

  const columns = [
    { key: 'sku', label: 'ID · Último ingreso', width: 'minmax(160px, 1fr)',
      render: i => <IdDateCell id={i.publicId} date={i.ultimoIngreso} /> },

    { key: 'detalle', label: 'Detalle', width: 'minmax(200px, 1fr)',
      render: i => (
        <div className="min-w-0">
          <p className="text-[13px] font-bold truncate" style={{ color: 'var(--foreground)' }}>{i.descripcion}</p>
          <p className="text-[11.5px] mt-0.5 truncate" style={{ color: 'var(--muted-foreground)' }}>
            <span className="font-mono font-bold tracking-wide uppercase" style={{ color: 'var(--accent)' }}>{i.sku}</span>
            <span style={{ margin: '0 6px' }}>·</span>
            {i.proveedor}
          </p>
        </div>
      ),
    },

    { key: 'lote', label: 'Lote', width: 'minmax(150px, 1fr)',
      render: i => {
        const ul = i.lotes[0];
        if (!ul) return <span style={{ color: 'color-mix(in oklab, var(--muted-foreground) 50%, transparent)', fontStyle: 'italic', fontSize: 12 }}>—</span>;
        const extra = i.lotes.length - 1;
        return (
          <div className="min-w-0">
            <p className="font-mono text-[11px] font-bold tracking-wide uppercase truncate" style={{ color: 'var(--foreground)' }}>{ul.lote}</p>
            {extra > 0
              ? <p className="text-[11px] tabular-nums mt-0.5 font-bold" style={{ color: 'var(--accent)' }}>(+{extra})</p>
              : <p className="text-[11px] mt-0.5" style={{ color: 'color-mix(in oklab, var(--muted-foreground) 55%, transparent)' }}>—</p>}
          </div>
        );
      },
    },

    { key: 'proxVenc', label: 'Próx. vencimiento', width: 'minmax(150px, 1fr)',
      render: i => {
        const prox = invProxVencimiento(i.lotes);
        if (!prox) return <span style={{ color: 'var(--muted-foreground)', fontSize: 12 }}>—</span>;
        const tr    = invTiempoRestante(prox);
        const color = tr.tone === 'neg' ? 'var(--danger)' : tr.tone === 'warn' ? 'var(--warning)' : 'var(--muted-foreground)';
        return (
          <div>
            <p className="text-[12px] font-bold" style={{ color }}>{tr.text}</p>
            <p className="text-[10.5px] tabular-nums mt-0.5" style={{ color: 'var(--muted-foreground)' }}>{formatDate(prox)}</p>
          </div>
        );
      },
    },

    { key: 'stock', label: 'Stock', width: 'minmax(100px, 1fr)',
      render: i => {
        const est = INVENTARIO_ESTADO(i);
        const color = est === 'ok' ? 'var(--foreground)' : est === 'bajo' ? 'var(--warning)' : 'var(--danger)';
        return (
          <div>
            <p className="text-[16px] font-extrabold tabular-nums" style={{ color }}>{i.stock}</p>
            <p className="text-[10.5px] tabular-nums" style={{ color: 'var(--muted-foreground)' }}>mín. {i.minimo}</p>
          </div>
        );
      },
    },

    { key: 'costoUnitario', label: 'Costo unit.', width: 'minmax(120px, 1fr)',
      render: i => (
        <span className="text-[13px] font-bold tabular-nums" style={{ color: 'var(--foreground)' }}>
          {formatMoney(i.costoUnitario)}
        </span>
      ),
    },
  ];

  // P23: columnas efectivas según dominio — Alquilables (equipo físico) vs Consumibles
  // (producto de stock, columnas de siempre).
  const effColumns = isAlq ? alqColumns : columns;
  const gridTpl   = effColumns.map(c => c.width || '1fr').join(' ');
  const minTableW = effColumns.reduce((s, c) => { const m = /(\d+)px/.exec(c.width || ''); return s + (m ? parseInt(m[1], 10) : 150); }, 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">
          <p className="text-[11px] font-bold uppercase tracking-[0.18em]" style={{ color: 'var(--accent)' }}>
            Operaciones · Inventario
          </p>
          <h1 className="text-[clamp(28px,4.2vw,40px)] leading-[1.05] font-extrabold tracking-[-0.02em]"
            style={{ color: 'var(--foreground)' }}>Inventario</h1>
          <p className="text-[13.5px] font-medium max-w-2xl" style={{ color: 'var(--muted-foreground)' }}>
            Productos en stock, lotes con vencimiento y movimientos de entrada/salida.
          </p>
        </div>
        <div className="flex items-center gap-2.5 flex-shrink-0">
          {isAlq && onVerHistorialAlquileres && (
            <button className="btn btn-outline" onClick={onVerHistorialAlquileres}>
              <IconClock size={15} /><span className="hidden sm:inline">Historial de alquileres</span>
            </button>
          )}
          <button className="btn btn-outline" onClick={() => setExportOpen(true)}><IconDownload size={15} /><span className="hidden sm:inline">Exportar</span></button>
          {/* DECISIÓN 2 (06/07/2026): "Nuevo ítem" ya no queda oculto en Alquilables —
              antes era `{!isAlq && ...}`, sin ningún alta de equipo VAC. Mismo botón,
              destino distinto según el dominio activo (producto vs. equipo físico). */}
          <button className="btn btn-primary" onClick={() => (isAlq ? (onNewActivo && onNewActivo()) : (onNew && onNew()))}>
            <IconPlus size={15} /><span className="hidden sm:inline">Nuevo ítem</span>
          </button>
        </div>
      </div>

      {/* Dominio (nivel 1, estilo Cobros/Pagos de Finanzas) */}
      <div className="flex items-center gap-2 animate-fade-in-up" style={{ animationDelay: '90ms' }}>
        {[{ k: 'consumibles', l: 'Consumibles' }, { k: 'alquilables', l: 'Alquilables' }].map(d => {
          const active = dominio === d.k;
          return (
            <button key={d.k} onClick={() => switchDominio(d.k)}
              className="chip flex-shrink-0 text-[13px] font-bold"
              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)' }}>
              {d.l}
            </button>
          );
        })}
      </div>

      {/* Categorías (tabs arriba, estilo Cobros/Pagos) + botón agregar sección */}
      <div className="flex items-center gap-3 animate-fade-in-up" style={{ animationDelay: '120ms' }}>
        <div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none flex-1 min-w-0">
          {categoriaTabs.map(tab => {
            const active = categoria === tab.value;
            return (
              <button key={tab.value ?? '__all__'} onClick={() => setCategoria(tab.value)}
                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)' }}>
                {tab.label}
              </button>
            );
          })}
        </div>
        <button
          onClick={() => { setNewCatName(''); setAddCatOpen(true); }}
          className="btn btn-primary flex-shrink-0">
          <IconPlus size={15} />
          <span className="hidden sm:inline">Agregar sección</span>
        </button>
      </div>

      {/* Modal: crear sección (categoría) */}
      {addCatOpen &&
      <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(8,20,24,0.55)' }} onClick={() => setAddCatOpen(false)}>
        <div className="rounded-2xl w-full max-w-sm 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)' }}>
          <div className="px-6 pt-6 pb-1">
            <p className="text-[15px] font-extrabold" style={{ color: 'var(--foreground)' }}>Nueva sección</p>
            <p className="text-[12.5px] mt-1" style={{ color: 'var(--muted-foreground)' }}>Creá una categoría para clasificar el inventario.</p>
          </div>
          <div className="px-6 py-4">
            <label className="text-[10.5px] font-bold uppercase tracking-[0.14em]" style={{ color: 'var(--muted-foreground)' }}>Nombre de la sección</label>
            <input autoFocus value={newCatName}
              onChange={(e) => setNewCatName(e.target.value)}
              onKeyDown={(e) => { if (e.key === 'Enter') confirmAddCat(); if (e.key === 'Escape') setAddCatOpen(false); }}
              placeholder="Ej. Vacunas, Descartables…"
              className="edit-input w-full mt-2 text-[14px] font-bold" style={{ color: 'var(--foreground)' }} />
          </div>
          <div className="px-6 pb-6 pt-1 flex items-center justify-end gap-2">
            <button onClick={() => setAddCatOpen(false)} className="btn btn-outline">Cancelar</button>
            <button onClick={confirmAddCat} className="btn btn-primary">Crear sección</button>
          </div>
        </div>
      </div>
      }
      <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 stagger">
        {isAlq ? <React.Fragment>
          <KpiCard label="Equipos"      value={String(_alqActivos.length)}                  delta={`${INVENTARIO.length} modelos`} deltaTone="neutral" icon={IconPackage} />
          <KpiCard label="Alquilados"   value={String(_alqCount('ALQUILADO'))}              delta="Facturando"   deltaTone="pos"     icon={IconDollar} />
          <KpiCard label="Estacionados" value={String(_alqCount('ESTACIONADO'))}            delta="Disponibles"  deltaTone="neutral" icon={IconCheckCircle} />
          <KpiCard label="Utilización"  value={_alqActivos.length ? Math.round(_alqCount('ALQUILADO') / _alqActivos.length * 100) + '%' : '—'} delta={`${_alqCount('EN_REPARACION')} en reparación`} deltaTone={_alqCount('EN_REPARACION') > 0 ? 'warn' : 'pos'} icon={IconClock} />
        </React.Fragment> : <React.Fragment>
          {(() => {
            // Fallback per-campo (mismo patrón robusto que Expedientes): si srvStats es
            // null (cargando/error) o llega parcial/{} (degradación documentada del
            // endpoint cuando RLS bloquea TODO), cada KPI cae al cálculo en memoria en vez
            // de renderizar "undefined"/"$NaN".
            const kVencidos = srvStats?.vencidos ?? vencidosCount;
            const kBajoCrit = srvBajoCritico ?? bajoCritico;
            // P24 (03/07/2026): `costo_unitario` está vacío en 600/601 productos reales
            // (verificado docker exec psql; el único no-null vale $0) — Fran: "los costos NO
            // existen, no se inventan; que la UI no mienta". `stock*costoUnitario` con
            // costoUnitario null/0 en TODOS lados suma un total real de $0 (ni la vista SQL ni
            // el fallback en memoria distinguen "no hay dato" de "vale cero real") — mostrar
            // "$0" ahí insinúa un cálculo verificado que no existe. `hasCosto` (memoria: alcanza,
            // hoy es 0/601 en TODA la ventana hidratada) decide entre el valor real y el estado
            // honesto "Sin datos".
            const hasCosto = INVENTARIO.some((i) => Number(i.costoUnitario) > 0);
            return <React.Fragment>
              <KpiCard label="Productos"        value={String(kProductos)} delta={`${new Set(INVENTARIO.map(i => i.categoria)).size} categorías`} deltaTone="neutral" icon={IconPackage} />
              <KpiCard label="Lotes vencidos"   value={String(kVencidos)}     delta={kVencidos > 0 ? 'Requieren atención' : 'Sin alertas'} deltaTone={kVencidos > 0 ? 'neg' : 'pos'} icon={IconClock} />
              <KpiCard label="Stock bajo/crít." value={String(kBajoCrit)}       delta={`${INVENTARIO.filter(i => INVENTARIO_ESTADO(i) === 'critico').length} críticos`} deltaTone={kBajoCrit > 0 ? 'warn' : 'pos'} icon={IconCheckCircle} />
              <KpiCard label="Valor de stock"
                value={hasCosto ? formatMoneyShort(srvStats?.valorTotal ?? valorTotal) : 'Sin datos'}
                delta={hasCosto ? 'Al costo' : 'Costeo no cargado'} deltaTone="neutral" icon={IconDollar} />
            </React.Fragment>;
          })()}
        </React.Fragment>}
      </div>

      {/* Filter chips — vencimiento (consumibles) / estado (alquilables) */}
      <div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none animate-fade-in-up" style={{ animationDelay: '200ms' }}>
        {(isAlq ? estadoChips : vencimientoChips).map(opt => {
          const active = (isAlq ? estadoAlq : vencimiento) === opt.value;
          const isVenc = opt.value === 'vencido';
          return (
            <button key={opt.value ?? '__all__'} onClick={() => (isAlq ? setEstadoAlq : setVencimiento)(opt.value)}
              className="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: isVenc && opt.count > 0 ? 'var(--danger)' : 'var(--muted-foreground)' }}>
              {opt.label}
              <span className="opacity-70 tabular-nums">{opt.count}</span>
            </button>
          );
        })}
      </div>

      {/* Table card */}
      <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',
      }}>
        <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={isAlq ? 'Buscar por interno, N° de serie o tamaño…' : 'Buscar por ID, SKU, descripción, proveedor o categoría…'}
              className="w-full h-10 pl-10 pr-3 text-[13px] font-medium rounded-lg outline-none"
              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">
            <TableDateRange from={dateFrom} to={dateTo} onFrom={setDateFrom} onTo={setDateTo} />
            {/* Hallazgo auditoría 05/07/2026 ("Contador 'N resultados' junto al buscador,
                dominio Consumibles"): antes mostraba SIEMPRE kProductos (universo completo
                del dominio, vía /stats) — con un filtro (categoría/vencimiento/búsqueda/rango
                de fechas) activo, `filtered` (lo que la tabla realmente pinta) podía ser un
                subconjunto mucho más chico y el número no reflejaba nada real. Mismo criterio
                que el fix gemelo de `ui/entity-list.jsx` (grupo cimiento, mismo día): sin
                filtro activo, kProductos sigue siendo el correcto (universo real, no lo
                cargado); con filtro activo, cae a `filtered.length` (honesto sobre el
                subconjunto). Alquilables: catálogo chico ya completo, siempre exacto. */}
            <span className="text-[12px] font-semibold tabular-nums whitespace-nowrap" style={{ color: 'var(--muted-foreground)' }}>
              {isAlq || hayFiltroConsumiblesActivo ? filtered.length : kProductos} resultados
            </span>
          </div>
        </div>
        <div style={{ overflowX: 'auto' }}>
          <div style={{ minWidth: `${minTableW}px` }}>
            <div className="grid gap-4 px-6 py-3.5 text-[10.5px] font-bold uppercase tracking-[0.08em]"
              style={{ gridTemplateColumns: gridTpl, color: 'var(--muted-foreground)', borderBottom: '1px solid color-mix(in oklab, var(--border) 60%, transparent)' }}>
              {effColumns.map((c, ci) => <div key={c.key} className={`${c.align === 'right' ? 'text-right' : c.align === 'center' ? 'text-center' : ''} ${ci === effColumns.length - 1 ? 'pr-[23px]' : ''}`}>{c.label}</div>)}
            </div>
            <div className="stagger-rows">
              {filtered.map((row, idx) => (
                <button key={row.id} onClick={() => (isAlq ? (onOpenActivo && onOpenActivo(row)) : onOpen(row))}
                  className="exp-row w-full grid gap-4 px-6 py-4 items-center text-left cursor-pointer"
                  style={{ gridTemplateColumns: gridTpl, borderBottom: idx < filtered.length - 1 ? '1px solid color-mix(in oklab, var(--border) 45%, transparent)' : 'none' }}>
                  {effColumns.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>;
                    return (
                    <div key={c.key} className={`min-w-0 ${c.align === 'right' ? 'text-right' : c.align === 'center' ? 'text-center' : ''}`}>
                      {ci === effColumns.length - 1 ? (
                        <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>
                    );
                  })}
                </button>
              ))}
              {/* Sentinel: SOLO Consumibles (server-side); al entrar en viewport (cerca
                  del fondo) carga la próxima tanda. Alquilables ya está completo (catálogo
                  chico), sin sentinel. */}
              {!isAlq && 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 */}
              {!isAlq && !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)' }}>
                    {!isAlq && pgLoading && pgRows.length === 0 ? 'Cargando inventario…' : 'Sin resultados.'}
                  </p>
                </div>
              )}
            </div>
          </div>
        </div>
      </div>

      {/* ── Modal exportar a Excel ── */}
      <ExportModal
        open={exportOpen}
        onClose={() => setExportOpen(false)}
        exportConfig={isAlq ? alqExportConfig : invExportConfig}
        rows={filtered}
        filename={isAlq ? 'Equipos-VAC' : 'Inventario'}
      />
    </div>
  );
}

Object.assign(window, { InventarioScreen });
