- Campo genera_monedero (default true) en servicios e items de inventario - Puntos proporcionales a las líneas elegibles al liquidar la venta - Toggle con icono Wallet en Servicios e Inventario
379 lines
18 KiB
TypeScript
379 lines
18 KiB
TypeScript
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<InventoryLevel, { label: string; cls: string }> = {
|
||
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<InventoryMoveType, { label: string; sign: string; cls: string }> = {
|
||
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<InventoryItem> = {
|
||
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<InventoryItem[]>([]);
|
||
const [summary, setSummary] = useState<InventorySummary>({ levels: { out: 0, critical: 0, low: 0, optimal: 0 }, total_value: 0, count: 0 });
|
||
const [loading, setLoading] = useState(true);
|
||
const [kind, setKind] = useState<string>('');
|
||
const [level, setLevel] = useState<string>('');
|
||
const [search, setSearch] = useState('');
|
||
|
||
const [newOpen, setNewOpen] = useState(false);
|
||
const [newForm, setNewForm] = useState<Partial<InventoryItem>>(emptyItem);
|
||
const [savingNew, setSavingNew] = useState(false);
|
||
|
||
const [adjustItem, setAdjustItem] = useState<InventoryItem | null>(null);
|
||
const [moveType, setMoveType] = useState<InventoryMoveType>('compra');
|
||
const [moveQty, setMoveQty] = useState('');
|
||
const [moveRef, setMoveRef] = useState('');
|
||
const [savingMove, setSavingMove] = useState(false);
|
||
|
||
const [historyItem, setHistoryItem] = useState<InventoryItem | null>(null);
|
||
const [moves, setMoves] = useState<InventoryMove[]>([]);
|
||
|
||
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 (
|
||
<Layout title="Inventario" subtitle="Productos y consumibles">
|
||
<PageHeader
|
||
title="Inventario y consumibles"
|
||
subtitle="Existencias, niveles y movimientos (compras, ventas, bajas, ajustes)"
|
||
>
|
||
{tab === 'items' && (
|
||
<Button onClick={() => setNewOpen(true)}>
|
||
<Plus size={16} className="mr-2" /> Nuevo item
|
||
</Button>
|
||
)}
|
||
</PageHeader>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||
{TABS.map((t) => (
|
||
<button
|
||
key={t.key}
|
||
type="button"
|
||
onClick={() => setTab(t.key)}
|
||
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
||
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||
}`}
|
||
>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === 'compras' ? (
|
||
<MovimientosPanel tipo="compra" />
|
||
) : tab === 'bajas' ? (
|
||
<MovimientosPanel tipo="baja" />
|
||
) : tab === 'alertas' ? (
|
||
<AlertasPanel />
|
||
) : (
|
||
<>
|
||
{/* KPIs */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4">
|
||
<Card><Card.Body>
|
||
<p className="text-xs text-theme-muted">Valor total</p>
|
||
<p className="text-xl font-semibold text-theme-heading">{fmtMoney(summary.total_value)}</p>
|
||
<p className="text-xs text-theme-muted">{summary.count} items</p>
|
||
</Card.Body></Card>
|
||
<Card><Card.Body>
|
||
<div className="flex items-center gap-2"><PackageCheck size={16} className="text-theme-heading" /><p className="text-xs text-theme-muted">Óptimo</p></div>
|
||
<p className="text-xl font-semibold text-theme-heading">{lvl.optimal}</p>
|
||
</Card.Body></Card>
|
||
<Card><Card.Body>
|
||
<div className="flex items-center gap-2"><AlertTriangle size={16} className="text-amber-600" /><p className="text-xs text-theme-muted">Bajo / Crítico</p></div>
|
||
<p className="text-xl font-semibold text-theme-warning-text">{lvl.low + lvl.critical}</p>
|
||
</Card.Body></Card>
|
||
<Card><Card.Body>
|
||
<div className="flex items-center gap-2"><Boxes size={16} className="text-rose-600" /><p className="text-xs text-theme-muted">Sin existencias</p></div>
|
||
<p className="text-xl font-semibold text-rose-700">{lvl.out}</p>
|
||
</Card.Body></Card>
|
||
</div>
|
||
|
||
{/* Filtros */}
|
||
<Card className="mb-4"><Card.Body>
|
||
<div className="flex flex-col lg:flex-row gap-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{KIND_OPTIONS.map((k) => (
|
||
<button key={k.value} onClick={() => setKind(k.value)}
|
||
className={`px-4 py-2 rounded-full text-sm font-medium transition ${kind === k.value ? 'bg-[#1A1A1A] text-white' : 'bg-theme-bg text-theme-muted hover:bg-theme-border'}`}>
|
||
{k.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex-1 min-w-[200px]">
|
||
<Input placeholder="Buscar por nombre, SKU o categoría" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||
</div>
|
||
<div className="w-full lg:w-56">
|
||
<Select options={LEVEL_OPTIONS} value={level} onChange={(e) => setLevel(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
</Card.Body></Card>
|
||
|
||
{/* Tabla */}
|
||
<Card><Card.Body noPadding>
|
||
{loading ? (
|
||
<p className="p-6 text-sm text-theme-muted">Cargando...</p>
|
||
) : items.length === 0 ? (
|
||
<div className="p-10 text-center text-theme-muted">
|
||
<Boxes className="mx-auto mb-2" size={28} />
|
||
<p className="text-sm">No hay items. Crea el primero con “Nuevo item”.</p>
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||
<tr>
|
||
<th className="text-left px-4 py-3">Item</th>
|
||
<th className="text-left px-4 py-3">Tipo</th>
|
||
<th className="text-right px-4 py-3">Existencia</th>
|
||
<th className="text-left px-4 py-3">Nivel</th>
|
||
<th className="text-right px-4 py-3">Costo</th>
|
||
<th className="text-right px-4 py-3">Valor</th>
|
||
<th className="text-left px-4 py-3">Caducidad</th>
|
||
<th className="px-4 py-3"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-theme-border">
|
||
{items.map((it) => (
|
||
<tr key={it.id} className="hover:bg-theme-bg">
|
||
<td className="px-4 py-3">
|
||
<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-4 py-3">
|
||
<span className={`text-xs px-2 py-1 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-4 py-3 text-right font-medium text-theme-heading">{it.qty} <span className="text-xs text-theme-muted">{it.unit}</span></td>
|
||
<td className="px-4 py-3"><span className={`text-xs px-2 py-1 rounded-full ${LEVEL_META[it.stock_level].cls}`}>{LEVEL_META[it.stock_level].label}</span></td>
|
||
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.cost)}</td>
|
||
<td className="px-4 py-3 text-right text-theme-heading">{fmtMoney(it.inventory_value)}</td>
|
||
<td className="px-4 py-3 text-theme-muted">{it.expiry_date || '—'}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex justify-end gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => toggleMonedero(it)}
|
||
title={it.genera_monedero === false ? 'No genera monedero — clic para activar' : 'Genera monedero — clic para desactivar'}
|
||
>
|
||
<Wallet
|
||
size={14}
|
||
className={it.genera_monedero === false ? 'text-theme-muted opacity-50' : 'text-theme-accent'}
|
||
/>
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => openHistory(it)}><History size={14} /></Button>
|
||
<Button size="sm" onClick={() => openAdjust(it)}>Ajustar</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</Card.Body></Card>
|
||
|
||
{/* Modal nuevo item */}
|
||
<Modal isOpen={newOpen} onClose={() => setNewOpen(false)} title="Nuevo item de inventario" maxWidth="lg"
|
||
footer={<>
|
||
<Button variant="ghost" onClick={() => setNewOpen(false)}>Cancelar</Button>
|
||
<Button onClick={createItem} loading={savingNew}>Guardar</Button>
|
||
</>}>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div className="sm:col-span-2"><Input label="Nombre" value={newForm.name || ''} onChange={(e) => setNewForm({ ...newForm, name: e.target.value })} /></div>
|
||
<Select label="Tipo" options={[{ value: 'producto', label: 'Producto' }, { value: 'consumible', label: 'Consumible' }]} value={newForm.kind || 'producto'} onChange={(e) => setNewForm({ ...newForm, kind: e.target.value as InventoryKind })} />
|
||
<Input label="Unidad" value={newForm.unit || ''} onChange={(e) => setNewForm({ ...newForm, unit: e.target.value })} placeholder="pieza / caja / vial" />
|
||
<Input label="SKU / Código" value={newForm.sku || ''} onChange={(e) => setNewForm({ ...newForm, sku: e.target.value })} />
|
||
<Input label="Línea / Categoría" value={newForm.category || ''} onChange={(e) => setNewForm({ ...newForm, category: e.target.value })} />
|
||
<Input label="Existencia inicial" type="number" min="0" step="0.01" value={String(newForm.qty ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty: parseFloat(e.target.value) || 0 })} />
|
||
<Input label="Costo unitario" type="number" min="0" step="0.01" value={String(newForm.cost ?? 0)} onChange={(e) => setNewForm({ ...newForm, cost: parseFloat(e.target.value) || 0 })} />
|
||
<Input label="Stock mínimo (crítico)" type="number" min="0" step="0.01" value={String(newForm.qty_min ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_min: parseFloat(e.target.value) || 0 })} />
|
||
<Input label="Nivel óptimo" type="number" min="0" step="0.01" value={String(newForm.qty_optimal ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_optimal: parseFloat(e.target.value) || 0 })} />
|
||
{newForm.kind === 'consumible' && (
|
||
<Input label="Caducidad" type="date" value={newForm.expiry_date || ''} onChange={(e) => setNewForm({ ...newForm, expiry_date: e.target.value || null })} />
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* Modal ajustar / movimiento */}
|
||
<Modal isOpen={!!adjustItem} onClose={() => setAdjustItem(null)} title={adjustItem ? `Ajustar: ${adjustItem.name}` : 'Ajustar'} maxWidth="md"
|
||
footer={<>
|
||
<Button variant="ghost" onClick={() => setAdjustItem(null)}>Cancelar</Button>
|
||
<Button onClick={submitMove} loading={savingMove}>Registrar</Button>
|
||
</>}>
|
||
{adjustItem && (
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-theme-muted">Existencia actual: <span className="font-semibold text-theme-heading">{adjustItem.qty} {adjustItem.unit}</span></p>
|
||
<Select label="Tipo de movimiento" options={MOVE_OPTIONS} value={moveType} onChange={(e) => setMoveType(e.target.value as InventoryMoveType)} />
|
||
<Input label={moveType === 'ajuste' ? 'Nuevo conteo (existencia final)' : 'Cantidad'} type="number" min="0" step="0.01" value={moveQty} onChange={(e) => setMoveQty(e.target.value)} />
|
||
<Input label="Referencia (opcional)" value={moveRef} onChange={(e) => setMoveRef(e.target.value)} placeholder="OC, folio, nota..." />
|
||
{moveType === 'ajuste' && <p className="text-xs text-sky-700 bg-sky-50 rounded-lg p-2">El ajuste fija la existencia al valor capturado (conteo físico).</p>}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* Modal historial */}
|
||
<Modal isOpen={!!historyItem} onClose={() => setHistoryItem(null)} title={historyItem ? `Movimientos: ${historyItem.name}` : 'Movimientos'} maxWidth="lg">
|
||
{moves.length === 0 ? (
|
||
<p className="text-sm text-theme-muted">Sin movimientos registrados.</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{moves.map((m) => (
|
||
<div key={m.id} className="flex items-center justify-between p-3 bg-theme-bg rounded-xl text-sm">
|
||
<div className="flex items-center gap-2">
|
||
{m.type === 'compra' ? <ArrowDownToLine size={16} className="text-theme-heading" /> : <ArrowUpFromLine size={16} className="text-theme-muted" />}
|
||
<div>
|
||
<p className={`font-medium ${MOVE_META[m.type].cls}`}>{MOVE_META[m.type].label} {MOVE_META[m.type].sign}{m.qty}</p>
|
||
<p className="text-xs text-theme-muted">{m.date}{m.reference ? ` · ${m.reference}` : ''}</p>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-theme-muted">{m.before_qty} → <span className="font-semibold text-theme-heading">{m.after_qty}</span></p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
</>
|
||
)}
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
export default Inventario;
|