Oleada UX 4: Pagos/Adeudos (cobro en 1 click con recibo, plan de pagos), Monedero (movimientos narrados, ajustes auditados, MXN+USD), Inventario (alta rapida, conteo fisico con ajuste e historial, alertas accionables) + fix de folios de pago y cantidades de movimientos
This commit is contained in:
204
frontend/src/components/inventario/ConteoFisicoModal.tsx
Normal file
204
frontend/src/components/inventario/ConteoFisicoModal.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user