Files
SKEEN-Proyecto/frontend/src/components/inventario/ConteoFisicoModal.tsx

205 lines
8.3 KiB
TypeScript

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<ConteoFisicoModalProps> = ({ isOpen, onClose, onSaved }) => {
const [items, setItems] = useState<InventoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [contados, setContados] = useState<Record<number, string>>({});
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Conteo físico de inventario"
maxWidth="2xl"
footer={
<div className="flex flex-wrap items-center justify-between gap-2 w-full">
<div className="text-xs text-theme-muted">
{resumen.dif} con diferencia · faltante <span className="text-rose-600 font-medium">{money(resumen.faltante)}</span> ·
sobrante <span className="text-emerald-700 font-medium">{money(resumen.sobrante)}</span> · neto{' '}
<span className="font-medium text-theme-heading">{money(resumen.neto)}</span>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button onClick={guardar} loading={saving}>
<ClipboardCheck size={14} className="mr-1.5" />
Guardar conteo
</Button>
</div>
</div>
}
>
<div className="space-y-4">
<p className="text-xs text-theme-muted">
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 <span className="font-medium">ajuste</span> por
cada diferencia y queda el historial del conteo.
</p>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Filtrar artículos por nombre, SKU o categoría..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
{loading ? (
<Skeleton count={8} className="h-10 w-full" />
) : items.length === 0 ? (
<p className="text-sm text-theme-muted py-6 text-center">No hay artículos activos en el inventario.</p>
) : (
<div className="max-h-[45vh] overflow-y-auto border border-theme-border rounded-xl">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase sticky top-0">
<tr>
<th className="text-left px-3 py-2">Artículo</th>
<th className="text-left px-3 py-2 hidden sm:table-cell">Tipo</th>
<th className="text-right px-3 py-2">Sistema</th>
<th className="text-right px-3 py-2 w-32">Contado</th>
<th className="text-right px-3 py-2">Diferencia</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{visibles.map((it) => {
const diff = valorContado(it) - it.qty;
return (
<tr key={it.id} className="hover:bg-theme-bg">
<td className="px-3 py-2">
<p className="font-medium text-theme-heading">{it.name}</p>
<p className="text-xs text-theme-muted">{[it.sku, it.category].filter(Boolean).join(' · ') || '—'}</p>
</td>
<td className="px-3 py-2 hidden sm:table-cell">
<span className={`text-xs px-2 py-0.5 rounded-full ${it.kind === 'consumible' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'}`}>
{it.kind === 'consumible' ? 'Consumible' : 'Producto'}
</span>
</td>
<td className="px-3 py-2 text-right text-theme-muted">{it.qty} {it.unit}</td>
<td className="px-3 py-2 text-right">
<input
type="number"
min={0}
step={0.01}
aria-label={`Contado ${it.name}`}
value={contados[it.id] ?? String(it.qty)}
onChange={(e) => 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'
}`}
/>
</td>
<td className={`px-3 py-2 text-right font-medium ${
diff < 0 ? 'text-rose-600' : diff > 0 ? 'text-emerald-700' : 'text-theme-muted'
}`}>
{Math.abs(diff) < 1e-9 ? '—' : `${diff > 0 ? '+' : ''}${Math.round(diff * 100) / 100}`}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<Input
label="Notas del conteo"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Ej. conteo mensual de consumibles"
/>
</div>
</Modal>
);
};
export default ConteoFisicoModal;