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:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View 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;