Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
315
frontend/src/pages/Inventario.tsx
Normal file
315
frontend/src/pages/Inventario.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
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 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 [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 () => {
|
||||
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]);
|
||||
|
||||
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 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)"
|
||||
>
|
||||
<Button onClick={() => setNewOpen(true)}>
|
||||
<Plus size={16} className="mr-2" /> Nuevo item
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* 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="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;
|
||||
Reference in New Issue
Block a user