import type { FC } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History, Wallet } from 'lucide-react'; import Layout from '../components/Layout'; import MovimientosPanel from '../components/inventario/MovimientosPanel'; import AlertasPanel from '../components/inventario/AlertasPanel'; import { Card, Button, Input, Select, Modal, PageHeader, toast } from '../components/ui'; import { odooApi } from '../services/odoo'; import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary } from '../services/odoo'; const TABS = [ { key: 'items', label: 'Items' }, { key: 'compras', label: 'Compras' }, { key: 'bajas', label: 'Bajas' }, { key: 'alertas', label: 'Alertas' }, ] as const; const fmtMoney = (n: number) => (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }); const LEVEL_META: Record = { out: { label: 'Sin existencias', cls: 'bg-rose-100 text-rose-700' }, critical: { label: 'Crítico', cls: 'bg-orange-100 text-orange-700' }, low: { label: 'Bajo', cls: 'bg-theme-bg text-theme-warning-text' }, optimal: { label: 'Óptimo', cls: 'bg-theme-success text-theme-heading' }, }; const MOVE_META: Record = { compra: { label: 'Compra', sign: '+', cls: 'text-theme-heading' }, venta: { label: 'Venta', sign: '−', cls: 'text-theme-muted' }, baja: { label: 'Baja', sign: '−', cls: 'text-rose-600' }, ajuste: { label: 'Ajuste', sign: '=', cls: 'text-sky-600' }, }; const KIND_OPTIONS = [ { value: '', label: 'Todos' }, { value: 'producto', label: 'Productos' }, { value: 'consumible', label: 'Consumibles' }, ]; const LEVEL_OPTIONS = [ { value: '', label: 'Todos los niveles' }, { value: 'optimal', label: 'Óptimo' }, { value: 'low', label: 'Bajo' }, { value: 'critical', label: 'Crítico' }, { value: 'out', label: 'Sin existencias' }, ]; const MOVE_OPTIONS = [ { value: 'compra', label: 'Compra (entrada)' }, { value: 'venta', label: 'Venta (salida)' }, { value: 'baja', label: 'Baja (merma)' }, { value: 'ajuste', label: 'Ajuste (fijar conteo físico)' }, ]; const emptyItem: Partial = { name: '', kind: 'producto', sku: '', category: '', unit: 'pieza', qty: 0, qty_optimal: 0, qty_min: 0, cost: 0, expiry_date: null, notes: '', }; const Inventario: FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const tab = searchParams.get('tab') || 'items'; const setTab = (t: string) => setSearchParams({ tab: t }); const [items, setItems] = useState([]); const [summary, setSummary] = useState({ levels: { out: 0, critical: 0, low: 0, optimal: 0 }, total_value: 0, count: 0 }); const [loading, setLoading] = useState(true); const [kind, setKind] = useState(''); const [level, setLevel] = useState(''); const [search, setSearch] = useState(''); const [newOpen, setNewOpen] = useState(false); const [newForm, setNewForm] = useState>(emptyItem); const [savingNew, setSavingNew] = useState(false); const [adjustItem, setAdjustItem] = useState(null); const [moveType, setMoveType] = useState('compra'); const [moveQty, setMoveQty] = useState(''); const [moveRef, setMoveRef] = useState(''); const [savingMove, setSavingMove] = useState(false); const [historyItem, setHistoryItem] = useState(null); const [moves, setMoves] = useState([]); const load = useCallback(async () => { if (tab !== 'items') return; setLoading(true); try { const res = await odooApi.getInventory({ ...(kind ? { kind: kind as InventoryKind } : {}), ...(level ? { level: level as InventoryLevel } : {}), ...(search ? { search } : {}), }); setItems(res.items || []); setSummary(res.summary); } catch { toast.error('No se pudo cargar el inventario'); } finally { setLoading(false); } }, [kind, level, search, tab]); useEffect(() => { load(); }, [load]); const createItem = async () => { if (!newForm.name) { toast.error('El nombre es obligatorio'); return; } setSavingNew(true); try { await odooApi.createInventoryItem(newForm); toast.success('Item creado'); setNewOpen(false); setNewForm(emptyItem); load(); } catch { toast.error('Error al crear el item'); } finally { setSavingNew(false); } }; const openAdjust = (it: InventoryItem) => { setAdjustItem(it); setMoveType('compra'); setMoveQty(''); setMoveRef(''); }; const submitMove = async () => { if (!adjustItem) return; const q = parseFloat(moveQty); if (isNaN(q) || q < 0) { toast.error('Cantidad inválida'); return; } setSavingMove(true); try { await odooApi.createInventoryMove(adjustItem.id, { type: moveType, qty: q, reference: moveRef }); toast.success('Movimiento registrado'); setAdjustItem(null); load(); } catch { toast.error('Error al registrar el movimiento'); } finally { setSavingMove(false); } }; const openHistory = async (it: InventoryItem) => { setHistoryItem(it); try { const res = await odooApi.getInventoryMoves(it.id); setMoves(res.moves || []); } catch { setMoves([]); } }; const toggleMonedero = async (it: InventoryItem) => { try { await odooApi.updateInventoryItem(it.id, { genera_monedero: it.genera_monedero === false }); toast.success(it.genera_monedero === false ? 'El artículo ahora genera monedero' : 'El artículo ya no genera monedero'); load(); } catch { toast.error('Error al actualizar monedero'); } }; const lvl = summary.levels; return ( {tab === 'items' && ( )} {/* Tabs */}
{TABS.map((t) => ( ))}
{tab === 'compras' ? ( ) : tab === 'bajas' ? ( ) : tab === 'alertas' ? ( ) : ( <> {/* KPIs */}

Valor total

{fmtMoney(summary.total_value)}

{summary.count} items

Óptimo

{lvl.optimal}

Bajo / Crítico

{lvl.low + lvl.critical}

Sin existencias

{lvl.out}

{/* Filtros */}
{KIND_OPTIONS.map((k) => ( ))}
setSearch(e.target.value)} />
setNewForm({ ...newForm, name: e.target.value })} />
setNewForm({ ...newForm, unit: e.target.value })} placeholder="pieza / caja / vial" /> setNewForm({ ...newForm, sku: e.target.value })} /> setNewForm({ ...newForm, category: e.target.value })} /> setNewForm({ ...newForm, qty: parseFloat(e.target.value) || 0 })} /> setNewForm({ ...newForm, cost: parseFloat(e.target.value) || 0 })} /> setNewForm({ ...newForm, qty_min: parseFloat(e.target.value) || 0 })} /> setNewForm({ ...newForm, qty_optimal: parseFloat(e.target.value) || 0 })} /> {newForm.kind === 'consumible' && ( setNewForm({ ...newForm, expiry_date: e.target.value || null })} /> )}
{/* Modal ajustar / movimiento */} setAdjustItem(null)} title={adjustItem ? `Ajustar: ${adjustItem.name}` : 'Ajustar'} maxWidth="md" footer={<> }> {adjustItem && (

Existencia actual: {adjustItem.qty} {adjustItem.unit}

setMoveQty(e.target.value)} /> setMoveRef(e.target.value)} placeholder="OC, folio, nota..." /> {moveType === 'ajuste' &&

El ajuste fija la existencia al valor capturado (conteo físico).

}
)}
{/* Modal historial */} setHistoryItem(null)} title={historyItem ? `Movimientos: ${historyItem.name}` : 'Movimientos'} maxWidth="lg"> {moves.length === 0 ? (

Sin movimientos registrados.

) : (
{moves.map((m) => (
{m.type === 'compra' ? : }

{MOVE_META[m.type].label} {MOVE_META[m.type].sign}{m.qty}

{m.date}{m.reference ? ` · ${m.reference}` : ''}

{m.before_qty} → {m.after_qty}

))}
)}
)}
); }; export default Inventario;