import type { FC } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { Search, ClipboardCheck } from 'lucide-react'; import { Button, Input, Modal, Skeleton, toast } from '../ui'; import { odooApi, type InventoryItem, type InventoryConteo } from '../../services/odoo'; const money = (v: number) => '$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); interface ConteoFisicoModalProps { isOpen: boolean; onClose: () => void; /** Devuelve el conteo guardado (con diferencias) para imprimir el resumen. */ onSaved: (conteo: InventoryConteo) => void; } // Conteo físico cíclico: lista los artículos activos con el stock del sistema // prellenado y aplica un ajuste por cada diferencia al guardar. export const ConteoFisicoModal: FC = ({ isOpen, onClose, onSaved }) => { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [contados, setContados] = useState>({}); const [notes, setNotes] = useState(''); const [saving, setSaving] = useState(false); useEffect(() => { if (!isOpen) return; setLoading(true); setContados({}); setNotes(''); setSearch(''); odooApi.getInventory({}) .then((res) => { const activos = (res.items || []).filter((i) => i.active !== false); setItems(activos); setContados(Object.fromEntries(activos.map((i) => [i.id, String(i.qty)]))); }) .catch(() => toast.error('No se pudo cargar el inventario')) .finally(() => setLoading(false)); }, [isOpen]); const visibles = useMemo(() => { const q = search.trim().toLowerCase(); if (!q) return items; return items.filter((i) => [i.name, i.sku, i.category].filter(Boolean).some((v) => String(v).toLowerCase().includes(q))); }, [items, search]); const valorContado = (it: InventoryItem) => { const raw = contados[it.id]; if (raw === undefined || raw === '') return it.qty; const n = parseFloat(raw); return Number.isNaN(n) ? it.qty : n; }; const resumen = useMemo(() => { let dif = 0; let faltante = 0; let sobrante = 0; for (const it of items) { const d = valorContado(it) - it.qty; if (Math.abs(d) < 1e-9) continue; dif += 1; const valor = d * (it.cost || 0); if (valor < 0) faltante += valor; else sobrante += valor; } return { dif, faltante, sobrante, neto: faltante + sobrante }; }, [items, contados]); const guardar = async () => { if (items.length === 0) { toast.error('No hay artículos activos para contar'); return; } try { setSaving(true); const res = await odooApi.createConteo({ notes, lines: items.map((i) => ({ item_id: i.id, qty_contado: valorContado(i) })), }); toast.success( `${res.conteo.name} guardado · ${res.ajustes} ${res.ajustes === 1 ? 'ajuste aplicado' : 'ajustes aplicados'}`, ); onClose(); onSaved(res.conteo); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'Error al guardar el conteo'); console.error(err); } finally { setSaving(false); } }; return (
{resumen.dif} con diferencia · faltante {money(resumen.faltante)} · sobrante {money(resumen.sobrante)} · neto{' '} {money(resumen.neto)}
} >

Captura la existencia real de cada artículo. El campo viene prellenado con la existencia del sistema; solo cambia lo que no coincida. Al guardar se genera un movimiento de ajuste por cada diferencia y queda el historial del conteo.

setSearch(e.target.value)} className="pl-10" />
{loading ? ( ) : items.length === 0 ? (

No hay artículos activos en el inventario.

) : (
{visibles.map((it) => { const diff = valorContado(it) - it.qty; return ( ); })}
Artículo Tipo Sistema Contado Diferencia

{it.name}

{[it.sku, it.category].filter(Boolean).join(' · ') || '—'}

{it.kind === 'consumible' ? 'Consumible' : 'Producto'} {it.qty} {it.unit} setContados({ ...contados, [it.id]: e.target.value })} className={`w-24 border rounded-lg px-2 py-1 text-sm text-right ${ Math.abs(diff) > 1e-9 ? 'border-amber-400 bg-amber-50' : 'border-theme-border-strong' }`} /> 0 ? 'text-emerald-700' : 'text-theme-muted' }`}> {Math.abs(diff) < 1e-9 ? '—' : `${diff > 0 ? '+' : ''}${Math.round(diff * 100) / 100}`}
)} setNotes(e.target.value)} placeholder="Ej. conteo mensual de consumibles" />
); }; export default ConteoFisicoModal;