205 lines
8.3 KiB
TypeScript
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;
|