Files
SKEEN-Proyecto/frontend/src/pages/Inventario.tsx
Consultoría Alcaraz Salazar 7bfa140d87 Monedero configurable por servicio y artículo
- 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
2026-08-14 08:13:16 +00:00

379 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;