fase 3.2: compras y proveedores (modulo skeen_compras: proveedores, ordenes de compra con recepcion total/parcial, costo promedio ponderado con lote y caducidad, pagos a proveedor, cuentas por pagar con antiguedad, reportes de compras y margen por articulo); apagado por defecto

This commit is contained in:
2026-10-11 12:17:23 +00:00
parent ee8a844d96
commit a916753ebe
38 changed files with 4201 additions and 30 deletions

View File

@@ -31,6 +31,7 @@ import Reportes from './pages/Reportes';
import Configuracion from './pages/Configuracion';
import Cumpleanos from './pages/Cumpleanos';
import Inventario from './pages/Inventario';
import Compras from './pages/Compras';
import WacrmMessages from './pages/WacrmMessages';
import WacrmLeads from './pages/WacrmLeads';
import Chat from './pages/Chat';
@@ -108,7 +109,7 @@ const LoginRoute: FC = () => {
const LANDING_CANDIDATOS = [
'/agenda', '/mis-ventas', '/pacientes', '/pos', '/visitas', '/consultas',
'/medicos', '/servicios', '/paquetes', '/ventas', '/pagos', '/monedero',
'/inventario', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages',
'/inventario', '/compras', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages',
'/wacrm/leads', '/configuracion',
];
@@ -121,6 +122,15 @@ const MembresiasRoute: FC = () => {
return <Membresias />;
};
// Compras vive detrás de `compras.activo`. Apagado, la ruta rebota a "/" aunque
// el rol tenga permiso de menú (así apagarlo vuelve todo al estado anterior,
// incluso entrando por URL).
const ComprasRoute: FC = () => {
const { compras } = useClinicSettings();
if (!compras.activo) return <Navigate to="/" replace />;
return <Compras />;
};
const HomeRoute: FC = () => {
const { user, canSee } = useAuth();
if (user?.role === 'medico') return <Navigate to="/agenda" replace />;
@@ -181,6 +191,8 @@ const App: FC = () => {
<Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} />
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
{/* Compras solo cuando la clínica la activa (compras.activo). */}
<Route path="/compras" element={<RequireAuth href="/compras"><ComprasRoute /></RequireAuth>} />
<Route path="/chat" element={<RequireAuth href="/chat"><Chat /></RequireAuth>} />
<Route path="/wacrm/messages" element={<RequireAuth href="/wacrm/messages"><WacrmMessages /></RequireAuth>} />
<Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />

View File

@@ -146,17 +146,23 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { membresias: membresiasCfg } = useClinicSettings();
const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
// Membresías solo cuando la clínica enciende `membresias.activo`.
const menuTreeT = useMemo<MenuEntry[]>(
() => (membresiasCfg.activo
? [...menuTree, { label: 'Membresías', icon: Repeat, href: '/membresias' }]
: menuTree),
[membresiasCfg.activo]
);
// Membresías y Compras solo cuando la clínica enciende su bandera.
const menuTreeT = useMemo<MenuEntry[]>(() => {
let tree = menuTree;
if (comprasCfg.activo) {
tree = tree.map((e) => (e.label === 'Consumibles' && e.children
? { ...e, children: [...e.children, { label: 'Órdenes de compra', href: '/compras' }] }
: e));
}
if (membresiasCfg.activo) {
tree = [...tree, { label: 'Membresías', icon: Repeat, href: '/membresias' }];
}
return tree;
}, [membresiasCfg.activo, comprasCfg.activo]);
const menuLeavesT = useMemo(
() => menuTreeT.flatMap((e) =>
e.children

View File

@@ -0,0 +1,149 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Save, Truck } from 'lucide-react';
import { Card, Button, Input, toast } from './ui';
import { odooApi, COMPRAS_DEFAULT, type ClinicSettings, type ComprasSettings } from '../services/odoo';
import ProveedoresPanel from './compras/ProveedoresPanel';
interface Props {
settings: ClinicSettings;
onSaved: (settings: ClinicSettings) => void;
}
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
<label className="flex items-start gap-3 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
<span>
<span className="block text-sm font-medium text-theme-heading">{label}</span>
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
</span>
</label>
);
// Configuración → Compras: ajustes del bloque `compras` + acceso a proveedores.
// Mientras `activo` esté apagado no aparece la sección en el menú y el
// inventario y el POS se ven idénticos; este panel es la llave para encenderlo.
const ComprasPanel: FC<Props> = ({ settings, onSaved }) => {
const [form, setForm] = useState<ComprasSettings>(settings.compras || COMPRAS_DEFAULT);
const [saving, setSaving] = useState(false);
useEffect(() => {
setForm(settings.compras || COMPRAS_DEFAULT);
}, [settings.compras]);
const set = <K extends keyof ComprasSettings>(k: K, v: ComprasSettings[K]) =>
setForm((prev) => ({ ...prev, [k]: v }));
const guardar = async () => {
setSaving(true);
try {
const res = await odooApi.putClinicSettings({
compras: {
...form,
dias_credito_default: Number(form.dias_credito_default) || 0,
margen_minimo_alerta: Number(form.margen_minimo_alerta) || 0,
},
motivo: 'Configuración de compras',
});
if (res.status === 'success') {
onSaved(res.settings);
toast.success('Configuración de compras guardada');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la configuración');
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
<Truck size={18} /> Compras y proveedores
</h3>
<p className="text-sm text-theme-muted mt-1">
Con el módulo apagado no aparece la sección Compras, el inventario y el POS se ven
igual que siempre y no se recalcula ningún costo.
</p>
</div>
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />Guardar</Button>
</div>
<div className="space-y-4">
<Toggle
label="Activar compras"
checked={form.activo}
onChange={(v) => set('activo', v)}
ayuda="Enciende la sección Compras, las recepciones, el costo promedio y los reportes."
/>
<Toggle
label="Actualizar costo promedio al recibir"
checked={form.actualizar_costo_promedio}
onChange={(v) => set('actualizar_costo_promedio', v)}
ayuda="Recalcula el costo promedio ponderado del artículo en cada recepción."
/>
<Toggle
label="Permitir recepción parcial"
checked={form.permitir_recepcion_parcial}
onChange={(v) => set('permitir_recepcion_parcial', v)}
ayuda="Permite recibir menos de lo pedido y completar después."
/>
<Toggle
label="Requiere autorización (enviar antes de recibir)"
checked={form.requiere_autorizacion}
onChange={(v) => set('requiere_autorizacion', v)}
ayuda="Las órdenes nacen en borrador y deben enviarse antes de poder recibirse."
/>
<Toggle
label="Permitir compras sin orden"
checked={form.permitir_compras_sin_orden}
onChange={(v) => set('permitir_compras_sin_orden', v)}
ayuda="Reservado para registrar recepciones directas sin orden de compra."
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Días de crédito por defecto"
type="number"
value={String(form.dias_credito_default)}
onChange={(e) => set('dias_credito_default', Number(e.target.value) as unknown as number)}
/>
<Input
label="Margen mínimo de alerta (%)"
type="number"
value={String(form.margen_minimo_alerta)}
onChange={(e) => set('margen_minimo_alerta', Number(e.target.value) as unknown as number)}
/>
</div>
</div>
<Card>
<Card.Header>
<p className="font-medium text-theme-heading">Proveedores</p>
</Card.Header>
<Card.Body>
{form.activo ? (
<ProveedoresPanel />
) : (
<p className="text-sm text-theme-muted">
Activa Compras y guarda para administrar proveedores desde aquí. También puedes
hacerlo desde Compras › Proveedores.
</p>
)}
</Card.Body>
</Card>
</div>
);
};
export default ComprasPanel;

View File

@@ -37,6 +37,7 @@ import {
Layers,
Download,
Repeat,
Truck,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -113,25 +114,32 @@ const MENU_SECTIONS = [
];
const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
const HOME_COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { membresias: membresiasCfg } = useClinicSettings();
const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
const topNavItems = useMemo(
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
[term]
);
const menuSections = useMemo(() => {
const base = membresiasCfg.activo
? MENU_SECTIONS.map((s) => (s.label === 'Clínica'
? { ...s, items: [...s.items, HOME_MEMBRESIA_ITEM] }
: s))
: MENU_SECTIONS;
let base = MENU_SECTIONS;
if (membresiasCfg.activo) {
base = base.map((s) => (s.label === 'Clínica'
? { ...s, items: [...s.items, HOME_MEMBRESIA_ITEM] }
: s));
}
if (comprasCfg.activo) {
base = base.map((s) => (s.label === 'Operaciones'
? { ...s, items: [...s.items, HOME_COMPRAS_ITEM] }
: s));
}
return traducirMenu(base, term);
}, [term, membresiasCfg.activo]);
}, [term, membresiasCfg.activo, comprasCfg.activo]);
const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
const chatUnread = useChatUnread();

View File

@@ -39,6 +39,7 @@ import {
Clock,
Download,
Repeat,
Truck,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -124,21 +125,29 @@ const menuGroups = [
// `membresias.activo`. Con el módulo apagado (default) el menú queda idéntico
// al de siempre.
const MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
// La sección Compras solo existe con `compras.activo=true`.
const COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { membresias: membresiasCfg } = useClinicSettings();
const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
const menuGroupsT = useMemo(() => {
const base = membresiasCfg.activo
? menuGroups.map((g) => (g.label === 'Clínica'
? { ...g, items: [...g.items, MEMBRESIA_ITEM] }
: g))
: menuGroups;
let base = menuGroups;
if (membresiasCfg.activo) {
base = base.map((g) => (g.label === 'Clínica'
? { ...g, items: [...g.items, MEMBRESIA_ITEM] }
: g));
}
if (comprasCfg.activo) {
base = base.map((g) => (g.label === 'Operaciones'
? { ...g, items: [...g.items, COMPRAS_ITEM] }
: g));
}
return traducirMenu(base, term);
}, [term, membresiasCfg.activo]);
}, [term, membresiasCfg.activo, comprasCfg.activo]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas

View File

@@ -0,0 +1,179 @@
import type { FC } from 'react';
import { Send, PackageCheck, CreditCard, Ban, Pencil } from 'lucide-react';
import { Modal, Button, Badge } from '../ui';
import type { Compra } from '../../services/odoo';
import { ESTADO_META, fmtMoney, fmtNum } from './shared';
interface Props {
isOpen: boolean;
onClose: () => void;
compra: Compra | null;
puedeEditar: boolean;
puedeRecibir: boolean;
puedePagar: boolean;
puedeCancelar: boolean;
puedeCrear: boolean;
onEdit: (c: Compra) => void;
onEnviar: (c: Compra) => void;
onRecibir: (c: Compra) => void;
onPagar: (c: Compra) => void;
onCancelar: (c: Compra) => void;
}
const CompraDetalleModal: FC<Props> = ({
isOpen, onClose, compra, puedeEditar, puedeRecibir, puedePagar, puedeCancelar, puedeCrear,
onEdit, onEnviar, onRecibir, onPagar, onCancelar,
}) => {
if (!compra) return null;
const meta = ESTADO_META[compra.estado];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Compra ${compra.folio}`}
maxWidth="full"
footer={(
<div className="flex flex-wrap justify-end gap-2 w-full">
{puedeEditar && ['borrador', 'enviada'].includes(compra.estado) && (
<Button variant="outline" onClick={() => onEdit(compra)}><Pencil size={14} className="mr-1.5" />Editar</Button>
)}
{puedeCrear && compra.estado === 'borrador' && (
<Button variant="outline" onClick={() => onEnviar(compra)}><Send size={14} className="mr-1.5" />Enviar</Button>
)}
{puedeRecibir && compra.puede_recibir && (
<Button onClick={() => onRecibir(compra)}><PackageCheck size={14} className="mr-1.5" />Recibir</Button>
)}
{puedePagar && compra.estado !== 'cancelada' && compra.saldo > 0 && (
<Button onClick={() => onPagar(compra)}><CreditCard size={14} className="mr-1.5" />Pagar</Button>
)}
{puedeCancelar && compra.puede_cancelar && (
<Button variant="danger" onClick={() => onCancelar(compra)}><Ban size={14} className="mr-1.5" />Cancelar</Button>
)}
</div>
)}
>
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-3">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.cls}`}>{meta.label}</span>
<span className="text-sm text-theme-heading">{compra.proveedor}</span>
<span className="text-xs text-theme-muted">Fecha {compra.fecha}</span>
{compra.fecha_entrega_esperada && <span className="text-xs text-theme-muted">Entrega esperada {compra.fecha_entrega_esperada}</span>}
<span className="text-xs text-theme-muted">Moneda {compra.moneda}</span>
{compra.recibida_at && <span className="text-xs text-theme-muted">Recibida {compra.recibida_at}</span>}
</div>
{compra.cancel_reason && (
<p className="text-xs text-rose-700">Cancelada: {compra.cancel_reason}</p>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="rounded-xl bg-theme-bg p-3">
<p className="text-[11px] text-theme-muted">Subtotal</p>
<p className="text-theme-heading font-medium tabular-nums">{fmtMoney(compra.subtotal, compra.moneda)}</p>
</div>
<div className="rounded-xl bg-theme-bg p-3">
<p className="text-[11px] text-theme-muted">Impuestos</p>
<p className="text-theme-heading font-medium tabular-nums">{fmtMoney(compra.impuestos, compra.moneda)}</p>
</div>
<div className="rounded-xl bg-theme-bg p-3">
<p className="text-[11px] text-theme-muted">Total</p>
<p className="text-theme-heading font-medium tabular-nums">{fmtMoney(compra.total, compra.moneda)}</p>
</div>
<div className="rounded-xl bg-theme-bg p-3">
<p className="text-[11px] text-theme-muted">Pagado / Saldo</p>
<p className="text-theme-heading font-medium tabular-nums">
{fmtMoney(compra.pagado, compra.moneda)} <span className="text-theme-muted">/ {fmtMoney(compra.saldo, compra.moneda)}</span>
</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Líneas</p>
<div className="overflow-x-auto border border-theme-border rounded-xl">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-3 py-2">Artículo</th>
<th className="text-right px-3 py-2">Pedida</th>
<th className="text-right px-3 py-2">Recibida</th>
<th className="text-right px-3 py-2">Pendiente</th>
<th className="text-right px-3 py-2">Costo</th>
<th className="text-right px-3 py-2">Subtotal</th>
<th className="text-left px-3 py-2">Lote / Cad.</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{(compra.lineas || []).map((l) => (
<tr key={l.id}>
<td className="px-3 py-2 text-theme-heading">{l.item}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.cantidad_pedida)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.cantidad_recibida)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.cantidad_pendiente)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(l.costo_unitario, compra.moneda)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(l.subtotal, compra.moneda)}</td>
<td className="px-3 py-2 text-[11px] text-theme-muted">
{l.lote || '—'}{l.fecha_caducidad ? ` · ${l.fecha_caducidad}` : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Recepciones</p>
{(compra.recepciones || []).length === 0 ? (
<p className="text-sm text-theme-muted">Sin recepciones todavía.</p>
) : (
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl">
{(compra.recepciones || []).map((r) => (
<li key={r.id} className="px-3 py-2 text-sm">
<div className="flex justify-between">
<span className="text-theme-heading">{r.fecha}</span>
<span className="text-theme-muted tabular-nums">{fmtNum(r.total_unidades)} u · {fmtMoney(r.total_costo, compra.moneda)}</span>
</div>
<p className="text-[11px] text-theme-muted">{r.usuario || '—'}{r.notas ? ` · ${r.notas}` : ''}</p>
<ul className="mt-1 space-y-0.5">
{r.lineas.map((ln) => (
<li key={ln.id} className="text-[11px] text-theme-muted">
{ln.item}: {fmtNum(ln.cantidad)} u{ln.lote ? ` · lote ${ln.lote}` : ''}{ln.fecha_caducidad ? ` · cad. ${ln.fecha_caducidad}` : ''}
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Pagos</p>
{(compra.pagos || []).length === 0 ? (
<p className="text-sm text-theme-muted">Sin pagos registrados.</p>
) : (
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl">
{(compra.pagos || []).map((p) => (
<li key={p.id} className="px-3 py-2 text-sm flex justify-between">
<div>
<p className="text-theme-heading">{fmtMoney(p.monto, p.moneda)}</p>
<p className="text-[11px] text-theme-muted">{p.fecha} · {p.metodo_label}{p.referencia ? ` · ${p.referencia}` : ''}</p>
</div>
{p.moneda !== compra.moneda && (
<span className="text-[11px] text-theme-muted tabular-nums">= {fmtMoney(p.monto_en_compra, compra.moneda)}</span>
)}
</li>
))}
</ul>
)}
</div>
</div>
{compra.notas && <p className="text-sm text-theme-muted">Observaciones: {compra.notas}</p>}
<Badge variant="info">Los pagos a proveedores son egresos y no se mezclan con los cobros a pacientes.</Badge>
</div>
</Modal>
);
};
export default CompraDetalleModal;

View File

@@ -0,0 +1,240 @@
import type { FC } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Modal, Button, Input, TextArea, Select, toast } from '../ui';
import { odooApi, type Compra, type InventoryItem, type MonedaCompra, type Proveedor } from '../../services/odoo';
import { MONEDA_OPTIONS, fmtMoney } from './shared';
interface LineaForm {
item_id: string;
cantidad_pedida: string;
costo_unitario: string;
descuento: string;
lote: string;
fecha_caducidad: string;
}
const emptyLinea: LineaForm = {
item_id: '', cantidad_pedida: '1', costo_unitario: '0', descuento: '0',
lote: '', fecha_caducidad: '',
};
interface Props {
isOpen: boolean;
onClose: () => void;
compra: Compra | null;
onDone: (compraId?: number) => void;
}
const hoy = () => new Date().toISOString().slice(0, 10);
const CompraFormModal: FC<Props> = ({ isOpen, onClose, compra, onDone }) => {
const [proveedores, setProveedores] = useState<Proveedor[]>([]);
const [items, setItems] = useState<InventoryItem[]>([]);
const [partnerId, setPartnerId] = useState('');
const [fecha, setFecha] = useState(hoy());
const [entrega, setEntrega] = useState('');
const [moneda, setMoneda] = useState<MonedaCompra>('MXN');
const [descuento, setDescuento] = useState('0');
const [referencia, setReferencia] = useState('');
const [notas, setNotas] = useState('');
const [lineas, setLineas] = useState<LineaForm[]>([{ ...emptyLinea }]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) return;
odooApi.getProveedores({ page_size: 500 })
.then((res) => { if (res.status === 'success') setProveedores(res.proveedores || []); })
.catch(() => {});
odooApi.getInventory()
.then((res) => setItems(res.items || []))
.catch(() => {});
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
if (compra) {
setPartnerId(String(compra.partner_id));
setFecha(compra.fecha || hoy());
setEntrega(compra.fecha_entrega_esperada || '');
setMoneda(compra.moneda);
setDescuento(String(compra.descuento || 0));
setReferencia(compra.referencia_factura || '');
setNotas(compra.notas || '');
setLineas((compra.lineas || []).map((l) => ({
item_id: String(l.item_id),
cantidad_pedida: String(l.cantidad_pedida),
costo_unitario: String(l.costo_unitario),
descuento: String(l.descuento || 0),
lote: l.lote || '',
fecha_caducidad: l.fecha_caducidad || '',
})));
} else {
setPartnerId(''); setFecha(hoy()); setEntrega(''); setMoneda('MXN');
setDescuento('0'); setReferencia(''); setNotas('');
setLineas([{ ...emptyLinea }]);
}
}, [isOpen, compra]);
const itemMap = useMemo(() => new Map(items.map((i) => [i.id, i])), [items]);
const setLinea = (idx: number, patch: Partial<LineaForm>) =>
setLineas((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)));
const onPickItem = (idx: number, itemId: string) => {
const it = itemMap.get(Number(itemId));
const costo = it ? (it.ultimo_costo || it.costo_promedio || it.cost || 0) : 0;
setLinea(idx, { item_id: itemId, costo_unitario: String(costo) });
};
const subtotalLinea = (l: LineaForm) =>
Math.max(0, (parseFloat(l.cantidad_pedida) || 0) * (parseFloat(l.costo_unitario) || 0) - (parseFloat(l.descuento) || 0));
const subtotal = lineas.reduce((s, l) => s + subtotalLinea(l), 0);
const desc = parseFloat(descuento) || 0;
const guardar = async () => {
if (!partnerId) { toast.error('Selecciona un proveedor'); return; }
const payload = lineas
.filter((l) => l.item_id && (parseFloat(l.cantidad_pedida) || 0) > 0)
.map((l) => ({
item_id: Number(l.item_id),
cantidad_pedida: parseFloat(l.cantidad_pedida) || 0,
costo_unitario: parseFloat(l.costo_unitario) || 0,
descuento: parseFloat(l.descuento) || 0,
lote: l.lote,
fecha_caducidad: l.fecha_caducidad || null,
}));
if (payload.length === 0) { toast.error('Agrega al menos una línea con artículo y cantidad'); return; }
setSaving(true);
try {
const body = {
partner_id: Number(partnerId),
fecha,
fecha_entrega_esperada: entrega || null,
moneda,
descuento: desc,
notas,
referencia_factura: referencia,
lineas: payload,
};
if (compra) {
await odooApi.updateCompra(compra.id, body);
} else {
await odooApi.crearOrdenCompra(body);
}
toast.success(compra ? 'Compra actualizada' : 'Compra creada');
onDone(compra?.id);
onClose();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la compra');
} finally {
setSaving(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={compra ? `Editar compra ${compra.folio}` : 'Nueva orden de compra'}
maxWidth="full"
footer={(
<div className="flex items-center justify-between gap-2 w-full">
<span className="text-sm text-theme-muted">
Subtotal <strong className="text-theme-heading">{fmtMoney(subtotal, moneda)}</strong>
{desc > 0 && <> · Descuento <strong className="text-theme-heading">{fmtMoney(desc, moneda)}</strong></>}
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button onClick={guardar} loading={saving}>Guardar</Button>
</div>
</div>
)}
>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Select
label="Proveedor"
options={[{ value: '', label: 'Selecciona…' }, ...proveedores.map((p) => ({ value: String(p.id), label: p.name }))]}
value={partnerId}
onChange={(e) => setPartnerId(e.target.value)}
/>
<Input label="Fecha" type="date" value={fecha} onChange={(e) => setFecha(e.target.value)} />
<Input label="Entrega esperada" type="date" value={entrega} onChange={(e) => setEntrega(e.target.value)} />
<Select label="Moneda" options={MONEDA_OPTIONS} value={moneda} onChange={(e) => setMoneda(e.target.value as MonedaCompra)} />
<Input label="Descuento global" type="number" value={descuento} onChange={(e) => setDescuento(e.target.value)} />
<Input label="Referencia / factura" value={referencia} onChange={(e) => setReferencia(e.target.value)} />
</div>
<div className="overflow-x-auto border border-theme-border rounded-xl">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-3 py-2 min-w-[200px]">Artículo</th>
<th className="text-right px-3 py-2 w-24">Cantidad</th>
<th className="text-right px-3 py-2 w-28">Costo unit.</th>
<th className="text-right px-3 py-2 w-24">Desc.</th>
<th className="text-left px-3 py-2 w-32">Lote</th>
<th className="text-left px-3 py-2 w-40">Caducidad</th>
<th className="text-right px-3 py-2 w-24">Subtotal</th>
<th className="px-3 py-2 w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{lineas.map((l, idx) => (
<tr key={idx}>
<td className="px-3 py-2">
<Select
options={[{ value: '', label: 'Selecciona…' }, ...items.map((it) => ({ value: String(it.id), label: it.name }))]}
value={l.item_id}
onChange={(e) => onPickItem(idx, e.target.value)}
/>
</td>
<td className="px-3 py-2">
<Input type="number" value={l.cantidad_pedida} onChange={(e) => setLinea(idx, { cantidad_pedida: e.target.value })} />
</td>
<td className="px-3 py-2">
<Input type="number" value={l.costo_unitario} onChange={(e) => setLinea(idx, { costo_unitario: e.target.value })} />
</td>
<td className="px-3 py-2">
<Input type="number" value={l.descuento} onChange={(e) => setLinea(idx, { descuento: e.target.value })} />
</td>
<td className="px-3 py-2">
<Input value={l.lote} onChange={(e) => setLinea(idx, { lote: e.target.value })} />
</td>
<td className="px-3 py-2">
<Input type="date" value={l.fecha_caducidad} onChange={(e) => setLinea(idx, { fecha_caducidad: e.target.value })} />
</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(subtotalLinea(l), moneda)}</td>
<td className="px-3 py-2">
<button
type="button"
className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"
onClick={() => setLineas((prev) => prev.length > 1 ? prev.filter((_, i) => i !== idx) : prev)}
title="Quitar línea"
>
<Trash2 size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Button variant="outline" size="sm" onClick={() => setLineas((prev) => [...prev, { ...emptyLinea }])}>
<Plus size={14} className="mr-1.5" />Agregar línea
</Button>
<TextArea label="Observaciones" rows={2} value={notas} onChange={(e) => setNotas(e.target.value)} />
<p className="text-[11px] text-theme-muted">
Los impuestos se calculan según la configuración de impuestos de la clínica. Si no están
activos, la compra queda sin impuesto.
</p>
</div>
</Modal>
);
};
export default CompraFormModal;

View File

@@ -0,0 +1,148 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { CreditCard, RefreshCw } from 'lucide-react';
import { Card, Button, EmptyState, toast } from '../ui';
import { odooApi, type CuentaPorPagar } from '../../services/odoo';
import { fmtMoney } from './shared';
import PagoCompraModal from './PagoCompraModal';
import { useAuth } from '../../lib/auth';
const CuentasPorPagarPanel: FC = () => {
const { can } = useAuth();
const [proveedores, setProveedores] = useState<CuentaPorPagar[]>([]);
const [buckets, setBuckets] = useState<{ key: string; label: string }[]>([]);
const [totales, setTotales] = useState<{
saldo: number; por_bucket: Record<string, number>; proveedores: number; compras: number;
} | null>(null);
const [loading, setLoading] = useState(true);
const [abierto, setAbierto] = useState<number | null>(null);
const [pago, setPago] = useState<{ id: number; folio: string; saldo: number; moneda: 'MXN' | 'USD' } | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await odooApi.getCuentasPorPagar();
if (res.status === 'success') {
setProveedores(res.proveedores || []);
setBuckets(res.buckets || []);
setTotales(res.totales || null);
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudieron cargar las cuentas por pagar');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-theme-muted">
{totales ? `${totales.proveedores} proveedor(es) · ${totales.compras} compra(s) con saldo` : ''}
</p>
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<Card><Card.Body>
<p className="text-xs text-theme-muted">Total por pagar</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(totales?.saldo || 0)}</p>
</Card.Body></Card>
{buckets.map((b) => (
<Card key={b.key}><Card.Body>
<p className="text-xs text-theme-muted">{b.label}</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(totales?.por_bucket?.[b.key] || 0)}</p>
</Card.Body></Card>
))}
</div>
{loading ? (
<Card><Card.Body><p className="text-sm text-theme-muted">Cargando…</p></Card.Body></Card>
) : proveedores.length === 0 ? (
<Card><Card.Body>
<EmptyState title="Sin cuentas por pagar" subtitle="Todas las compras están pagadas o no hay compras con saldo." />
</Card.Body></Card>
) : (
<div className="space-y-3">
{proveedores.map((p) => (
<Card key={p.partner_id}>
<Card.Body noPadding>
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 hover:bg-theme-bg"
onClick={() => setAbierto(abierto === p.partner_id ? null : p.partner_id)}
>
<div className="text-left">
<p className="text-theme-heading font-medium">{p.proveedor}</p>
<p className="text-[11px] text-theme-muted">{p.compras} compra(s) · crédito {p.dias_credito} días</p>
</div>
<div className="flex items-center gap-4">
{buckets.map((b) => (p.buckets[b.key] ? (
<span key={b.key} className="hidden md:inline text-[11px] text-theme-muted">
{b.label}: <strong className="text-theme-heading tabular-nums">{fmtMoney(p.buckets[b.key])}</strong>
</span>
) : null))}
<span className="text-theme-heading font-semibold tabular-nums">{fmtMoney(p.saldo)}</span>
</div>
</button>
{abierto === p.partner_id && (
<div className="border-t border-theme-border overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-2">Folio</th>
<th className="text-left px-4 py-2">Fecha</th>
<th className="text-left px-4 py-2">Vence</th>
<th className="text-right px-4 py-2">Días</th>
<th className="text-right px-4 py-2">Total</th>
<th className="text-right px-4 py-2">Pagado</th>
<th className="text-right px-4 py-2">Saldo</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{p.detalle.map((d) => (
<tr key={d.id}>
<td className="px-4 py-2 text-theme-heading">{d.folio}</td>
<td className="px-4 py-2 text-theme-muted">{d.fecha}</td>
<td className="px-4 py-2 text-theme-muted">{d.fecha_vencimiento}</td>
<td className="px-4 py-2 text-right tabular-nums">{d.dias}</td>
<td className="px-4 py-2 text-right tabular-nums">{fmtMoney(d.total, d.moneda)}</td>
<td className="px-4 py-2 text-right tabular-nums">{fmtMoney(d.pagado, d.moneda)}</td>
<td className="px-4 py-2 text-right tabular-nums font-medium">{fmtMoney(d.saldo, d.moneda)}</td>
<td className="px-4 py-2 text-right">
{can('compras.pagar') && (
<Button size="sm" variant="outline" onClick={() => setPago({ id: d.id, folio: d.folio, saldo: d.saldo, moneda: d.moneda })}>
<CreditCard size={13} className="mr-1.5" />Pagar
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
))}
</div>
)}
<PagoCompraModal
isOpen={!!pago}
onClose={() => setPago(null)}
compraId={pago?.id ?? null}
folio={pago?.folio || ''}
saldo={pago?.saldo || 0}
moneda={pago?.moneda || 'MXN'}
onDone={() => load()}
/>
</div>
);
};
export default CuentasPorPagarPanel;

View File

@@ -0,0 +1,88 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Modal, Button, Input, TextArea, Select, toast } from '../ui';
import { odooApi, type MonedaCompra, type MetodoPagoCompra } from '../../services/odoo';
import { METODO_OPTIONS, MONEDA_OPTIONS, fmtMoney } from './shared';
interface Props {
isOpen: boolean;
onClose: () => void;
compraId: number | null;
folio: string;
saldo: number;
moneda: MonedaCompra;
onDone: (compraId: number) => void;
}
const PagoCompraModal: FC<Props> = ({ isOpen, onClose, compraId, folio, saldo, moneda, onDone }) => {
const [monto, setMonto] = useState('');
const [monedaPago, setMonedaPago] = useState<MonedaCompra>(moneda);
const [metodo, setMetodo] = useState<MetodoPagoCompra>('transferencia');
const [referencia, setReferencia] = useState('');
const [notas, setNotas] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen) {
setMonto(saldo > 0 ? String(saldo) : '');
setMonedaPago(moneda);
setMetodo('transferencia');
setReferencia('');
setNotas('');
}
}, [isOpen, saldo, moneda]);
const guardar = async () => {
if (!compraId) return;
const m = parseFloat(monto);
if (!m || m <= 0) { toast.error('Ingresa un monto válido'); return; }
setSaving(true);
try {
await odooApi.pagarCompra(compraId, {
monto: m, moneda: monedaPago, metodo, referencia, notas,
});
toast.success('Pago registrado');
onDone(compraId);
onClose();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo registrar el pago');
} finally {
setSaving(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Pagar compra ${folio}`}
footer={(
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button onClick={guardar} loading={saving}>Registrar pago</Button>
</div>
)}
>
<div className="space-y-4">
<div className="rounded-xl bg-theme-bg p-3 text-sm">
<span className="text-theme-muted">Saldo pendiente: </span>
<span className="text-theme-heading font-semibold tabular-nums">{fmtMoney(saldo, moneda)}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Monto" type="number" value={monto} onChange={(e) => setMonto(e.target.value)} />
<Select label="Moneda" options={MONEDA_OPTIONS} value={monedaPago} onChange={(e) => setMonedaPago(e.target.value as MonedaCompra)} />
<Select label="Método" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value as MetodoPagoCompra)} />
<Input label="Referencia" value={referencia} onChange={(e) => setReferencia(e.target.value)} placeholder="Folio, transferencia…" />
</div>
<TextArea label="Notas" rows={2} value={notas} onChange={(e) => setNotas(e.target.value)} />
<p className="text-[11px] text-theme-muted">
Este pago es un EGRESO a proveedor: no se mezcla con los cobros a pacientes
(`skeen.pago`), por lo que no aparece en cortes de caja ni en estados de cuenta.
</p>
</div>
</Modal>
);
};
export default PagoCompraModal;

View File

@@ -0,0 +1,257 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Power, Search, RefreshCw } from 'lucide-react';
import { Card, Button, Input, TextArea, Modal, toast, EmptyState } from '../ui';
import { odooApi, type Proveedor } from '../../services/odoo';
import { useAuth } from '../../lib/auth';
import { useClinicSettings } from '../../lib/clinic-settings';
import { fmtMoney } from './shared';
interface ProveedorForm {
id?: number;
name: string;
contacto: string;
phone: string;
email: string;
vat: string;
dias_credito: string;
banco: string;
cuenta_pago: string;
notas: string;
activo: boolean;
}
const empty: ProveedorForm = {
name: '', contacto: '', phone: '', email: '', vat: '', dias_credito: '0',
banco: '', cuenta_pago: '', notas: '', activo: true,
};
const ProveedoresPanel: FC = () => {
const { can } = useAuth();
const { compras } = useClinicSettings();
const puedeEditar = can('proveedores.editar');
const [rows, setRows] = useState<Proveedor[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState('');
const [incluirInactivos, setIncluirInactivos] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<ProveedorForm>(empty);
const [saving, setSaving] = useState(false);
const [baja, setBaja] = useState<Proveedor | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await odooApi.getProveedores({ q: q || undefined, includeInactive: incluirInactivos });
if (res.status === 'success') setRows(res.proveedores || []);
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudieron cargar los proveedores');
} finally {
setLoading(false);
}
}, [q, incluirInactivos]);
useEffect(() => { load(); }, [load]);
const abrirNuevo = () => {
setForm({ ...empty, dias_credito: String(compras?.dias_credito_default ?? 0) });
setFormOpen(true);
};
const abrirEditar = (p: Proveedor) => {
setForm({
id: p.id, name: p.name, contacto: p.contacto || '', phone: p.phone || '',
email: p.email || '', vat: p.vat || '', dias_credito: String(p.dias_credito || 0),
banco: p.banco || '', cuenta_pago: p.cuenta_pago || '', notas: p.notas || '',
activo: p.activo,
});
setFormOpen(true);
};
const guardar = async () => {
if (!form.name.trim()) { toast.error('El nombre es obligatorio'); return; }
setSaving(true);
try {
const payload = {
name: form.name.trim(),
skeen_contacto: form.contacto, phone: form.phone, email: form.email, vat: form.vat,
dias_credito: parseInt(form.dias_credito || '0', 10) || 0,
skeen_banco: form.banco, skeen_cuenta_pago: form.cuenta_pago,
skeen_notas_proveedor: form.notas, activo: form.activo,
};
if (form.id) await odooApi.updateProveedor(form.id, payload);
else await odooApi.createProveedor(payload);
toast.success(form.id ? 'Proveedor actualizado' : 'Proveedor creado');
setFormOpen(false);
load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar el proveedor');
} finally {
setSaving(false);
}
};
const toggleActivo = async (p: Proveedor) => {
try {
await odooApi.updateProveedor(p.id, { activo: !p.activo });
toast.success(p.activo ? 'Proveedor desactivado' : 'Proveedor reactivado');
load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo cambiar el estado');
}
};
const eliminar = async () => {
if (!baja) return;
try {
const res = await odooApi.deleteProveedor(baja.id);
if (res.status === 'success') toast.success('Proveedor eliminado');
else toast.error(res.message || 'No se pudo eliminar');
setBaja(null);
load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo eliminar el proveedor');
setBaja(null);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px]">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
className="pl-9"
placeholder="Buscar por nombre, correo, teléfono o RFC"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<label className="flex items-center gap-2 text-sm text-theme-muted whitespace-nowrap">
<input type="checkbox" checked={incluirInactivos} onChange={(e) => setIncluirInactivos(e.target.checked)} />
Incluir inactivos
</label>
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
{puedeEditar && (
<Button onClick={abrirNuevo}><Plus size={16} className="mr-1.5" />Nuevo proveedor</Button>
)}
</div>
<Card>
<Card.Body noPadding>
{loading ? (
<div className="p-6 text-sm text-theme-muted">Cargando proveedores…</div>
) : rows.length === 0 ? (
<div className="p-6">
<EmptyState title="Sin proveedores" subtitle="Da de alta tu primer proveedor para comenzar a comprar." />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Proveedor</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Contacto</th>
<th className="text-left px-4 py-3 hidden lg:table-cell">Teléfono / correo</th>
<th className="text-right px-4 py-3">Crédito</th>
<th className="text-right px-4 py-3">Compras</th>
<th className="text-right px-4 py-3">Saldo</th>
<th className="text-right px-4 py-3">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{rows.map((p) => (
<tr key={p.id} className={p.activo ? '' : 'opacity-60'}>
<td className="px-4 py-3">
<p className="text-theme-heading font-medium">{p.name}</p>
{p.vat && <p className="text-[11px] text-theme-muted">{p.vat}</p>}
{!p.activo && <span className="text-[11px] text-rose-600">Inactivo</span>}
</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{p.contacto || '—'}</td>
<td className="px-4 py-3 text-theme-muted hidden lg:table-cell">
<span className="block">{p.phone || '—'}</span>
<span className="block text-[11px]">{p.email || ''}</span>
</td>
<td className="px-4 py-3 text-right tabular-nums">{p.dias_credito} días</td>
<td className="px-4 py-3 text-right tabular-nums">{p.compras_count}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.saldo)}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{puedeEditar ? (
<>
<button type="button" title="Editar" onClick={() => abrirEditar(p)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted">
<Pencil size={15} />
</button>
<button type="button" title={p.activo ? 'Desactivar' : 'Reactivar'} onClick={() => toggleActivo(p)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted">
<Power size={15} />
</button>
{p.compras_count === 0 && (
<button type="button" title="Eliminar" onClick={() => setBaja(p)} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600">
<Trash2 size={15} />
</button>
)}
</>
) : <span className="text-[11px] text-theme-muted">—</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
<Modal
isOpen={formOpen}
onClose={() => setFormOpen(false)}
title={form.id ? 'Editar proveedor' : 'Nuevo proveedor'}
maxWidth="lg"
footer={(
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancelar</Button>
<Button onClick={guardar} loading={saving}>Guardar</Button>
</div>
)}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Nombre / razón social" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Input label="Contacto de compras" value={form.contacto} onChange={(e) => setForm({ ...form, contacto: e.target.value })} />
<Input label="Teléfono" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<Input label="Correo" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
<Input label="RFC / identificación" value={form.vat} onChange={(e) => setForm({ ...form, vat: e.target.value })} />
<Input label="Días de crédito" type="number" value={form.dias_credito} onChange={(e) => setForm({ ...form, dias_credito: e.target.value })} />
<Input label="Banco" value={form.banco} onChange={(e) => setForm({ ...form, banco: e.target.value })} />
<Input label="Cuenta / CLABE" value={form.cuenta_pago} onChange={(e) => setForm({ ...form, cuenta_pago: e.target.value })} />
<div className="sm:col-span-2">
<TextArea label="Notas" rows={3} value={form.notas} onChange={(e) => setForm({ ...form, notas: e.target.value })} />
</div>
</div>
</Modal>
<Modal
isOpen={!!baja}
onClose={() => setBaja(null)}
title="Eliminar proveedor"
footer={(
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setBaja(null)}>Cancelar</Button>
<Button variant="danger" onClick={eliminar}>Eliminar</Button>
</div>
)}
>
<p className="text-sm text-theme-muted">
¿Eliminar a <strong className="text-theme-heading">{baja?.name}</strong>? Solo se permite
si no tiene compras registradas.
</p>
</Modal>
</div>
);
};
export default ProveedoresPanel;

View File

@@ -0,0 +1,191 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { Modal, Button, Input, TextArea, toast } from '../ui';
import { odooApi, type Compra } from '../../services/odoo';
import { useClinicSettings } from '../../lib/clinic-settings';
import { fmtNum, fmtMoney } from './shared';
interface LineaForm {
linea_id: number;
item: string;
unit: string;
kind: string;
pedida: number;
recibida: number;
pendiente: number;
costo_unitario: number;
cantidad: string;
lote: string;
fecha_caducidad: string;
}
interface Props {
isOpen: boolean;
onClose: () => void;
compra: Compra | null;
onDone: (compraId: number) => void;
}
const RecepcionModal: FC<Props> = ({ isOpen, onClose, compra, onDone }) => {
const { compras } = useClinicSettings();
const [lineas, setLineas] = useState<LineaForm[]>([]);
const [notas, setNotas] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen && compra) {
setLineas((compra.lineas || []).map((l) => ({
linea_id: l.id,
item: l.item,
unit: l.unit,
kind: l.kind,
pedida: l.cantidad_pedida,
recibida: l.cantidad_recibida,
pendiente: l.cantidad_pendiente,
costo_unitario: l.costo_unitario,
cantidad: String(l.cantidad_pendiente),
lote: l.lote || '',
fecha_caducidad: l.fecha_caducidad || '',
})));
setNotas('');
}
}, [isOpen, compra]);
const setLinea = (idx: number, patch: Partial<LineaForm>) =>
setLineas((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)));
const diferencias = lineas.filter((l) => {
const c = parseFloat(l.cantidad) || 0;
return c > 0 && Math.abs(c - l.pendiente) > 1e-6;
});
const excesos = lineas.filter((l) => (parseFloat(l.cantidad) || 0) > l.pendiente + 1e-6);
const totalRecibir = lineas.reduce((s, l) => s + (parseFloat(l.cantidad) || 0), 0);
const confirmar = async () => {
if (!compra) return;
const payload = lineas
.filter((l) => (parseFloat(l.cantidad) || 0) > 0)
.map((l) => ({
linea_id: l.linea_id,
cantidad: parseFloat(l.cantidad) || 0,
lote: l.lote,
fecha_caducidad: l.fecha_caducidad || null,
costo_unitario: l.costo_unitario,
}));
if (payload.length === 0) { toast.error('Indica al menos una cantidad a recibir'); return; }
setSaving(true);
try {
await odooApi.recibirCompra(compra.id, { lineas: payload, notas });
toast.success('Recepción registrada: stock y costo actualizados');
onDone(compra.id);
onClose();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo registrar la recepción');
} finally {
setSaving(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Recibir mercancía · ${compra?.folio || ''}`}
maxWidth="full"
footer={(
<div className="flex justify-between items-center gap-2 w-full">
<span className="text-xs text-theme-muted">
{fmtNum(totalRecibir)} unidades por recibir
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button onClick={confirmar} loading={saving}>Confirmar recepción</Button>
</div>
</div>
)}
>
<div className="space-y-4">
<div className="rounded-xl bg-amber-50 border border-amber-200 p-3 text-xs text-amber-800 flex gap-2">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<span>
Esta acción afecta el <strong>stock</strong> y, si la clínica lo tiene activado, el
<strong> costo promedio</strong> del artículo. Se permiten recepciones parciales:
lo que no recibas quedará pendiente para una recepción posterior.
</span>
</div>
<div className="overflow-x-auto border border-theme-border rounded-xl">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-3 py-2">Artículo</th>
<th className="text-right px-3 py-2">Pedida</th>
<th className="text-right px-3 py-2">Recibida</th>
<th className="text-right px-3 py-2">Pendiente</th>
<th className="text-right px-3 py-2 w-28">Recibir</th>
<th className="text-left px-3 py-2 w-32">Lote</th>
<th className="text-left px-3 py-2 w-40">Caducidad</th>
<th className="text-right px-3 py-2">Costo</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{lineas.map((l, idx) => {
const c = parseFloat(l.cantidad) || 0;
const dif = c > 0 && Math.abs(c - l.pendiente) > 1e-6;
const exceso = c > l.pendiente + 1e-6;
return (
<tr key={l.linea_id}>
<td className="px-3 py-2">
<p className="text-theme-heading">{l.item}</p>
<p className="text-[11px] text-theme-muted">{l.unit}{l.kind === 'consumible' ? ' · consumible' : ''}</p>
</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.pedida)}</td>
<td className="px-3 py-2 text-right tabular-nums text-theme-muted">{fmtNum(l.recibida)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.pendiente)}</td>
<td className="px-3 py-2">
<Input
type="number"
value={l.cantidad}
onChange={(e) => setLinea(idx, { cantidad: e.target.value })}
inputClassName={exceso ? 'border-rose-300' : dif ? 'border-amber-300' : undefined}
/>
</td>
<td className="px-3 py-2">
<Input value={l.lote} onChange={(e) => setLinea(idx, { lote: e.target.value })} />
</td>
<td className="px-3 py-2">
<Input type="date" value={l.fecha_caducidad} onChange={(e) => setLinea(idx, { fecha_caducidad: e.target.value })} />
</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(l.costo_unitario)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{excesos.length > 0 && (
<p className="text-xs text-rose-600">
Hay líneas con cantidad mayor a lo pendiente; se ajustarán al pendiente.
</p>
)}
{diferencias.length > 0 && excesos.length === 0 && (
<p className="text-xs text-amber-700">
Diferencias contra lo pedido en {diferencias.length} línea(s): se registrará una recepción parcial.
</p>
)}
<TextArea label="Notas de la recepción" rows={2} value={notas} onChange={(e) => setNotas(e.target.value)} />
{compras?.permitir_recepcion_parcial === false && (
<p className="text-[11px] text-theme-muted">
Esta clínica no permite recepciones parciales: cada línea debe recibirse completa.
</p>
)}
</div>
</Modal>
);
};
export default RecepcionModal;

View File

@@ -0,0 +1,29 @@
import type { EstadoCompra } from '../../services/odoo';
export const fmtMoney = (value: number, currency = 'MXN') =>
new Intl.NumberFormat('es-MX', {
style: 'currency', currency, maximumFractionDigits: 2,
}).format(value || 0);
export const fmtNum = (value: number) =>
new Intl.NumberFormat('es-MX', { maximumFractionDigits: 2 }).format(value || 0);
export const ESTADO_META: Record<EstadoCompra, { label: string; cls: string }> = {
borrador: { label: 'Borrador', cls: 'bg-slate-200 text-slate-700' },
enviada: { label: 'Enviada', cls: 'bg-sky-200 text-sky-900' },
recibida_parcial: { label: 'Recibida parcial', cls: 'bg-amber-200 text-amber-900' },
recibida: { label: 'Recibida', cls: 'bg-theme-success text-theme-heading' },
cancelada: { label: 'Cancelada', cls: 'bg-rose-200 text-rose-900' },
};
export const METODO_OPTIONS = [
{ value: 'transferencia', label: 'Transferencia' },
{ value: 'efectivo', label: 'Efectivo' },
{ value: 'tarjeta', label: 'Tarjeta' },
{ value: 'otro', label: 'Otro' },
];
export const MONEDA_OPTIONS = [
{ value: 'MXN', label: 'MXN (pesos)' },
{ value: 'USD', label: 'USD (dólares)' },
];

View File

@@ -0,0 +1,185 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { RefreshCw, Download } from 'lucide-react';
import { Card, Button, EmptyState, Skeleton, toast } from '../ui';
import { odooApi, type ComprasReportResponse } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
import { useAuth } from '../../lib/auth';
import { exportToExcel } from '../../lib/exporter';
import { fmtMoney } from '../compras/shared';
const ComprasReport: FC = () => {
const { start, end } = useReportRange('compras');
const { brand } = useAuth();
const [data, setData] = useState<ComprasReportResponse | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getComprasReport(start, end);
if (res.status === 'success') setData(res);
else toast.error(res.message || 'No se pudo cargar el reporte');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cargar el reporte de compras');
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const exportar = async () => {
if (!data) return;
try {
await exportToExcel({
filename: `reporte-compras-${data.start}_${data.end}`,
sheetName: 'Compras',
title: `Reporte de compras · ${brand.nombre}`,
subtitle: `Periodo ${data.start} a ${data.end}`,
columns: [
{ header: 'Concepto', key: 'concepto' },
{ header: 'Compras', key: 'compras', format: 'number' },
{ header: 'Total', key: 'total', format: 'currency' },
{ header: 'Pagado', key: 'pagado', format: 'currency' },
{ header: 'Saldo', key: 'saldo', format: 'currency' },
],
rows: [
{ concepto: 'Totales', compras: data.totales.compras, total: data.totales.total, pagado: data.totales.pagado, saldo: data.totales.saldo },
...data.por_proveedor.map((p) => ({ concepto: p.proveedor, compras: p.compras, total: p.total, pagado: p.pagado, saldo: p.saldo })),
],
});
toast.success('Excel descargado');
} catch {
toast.error('No se pudo exportar');
}
};
if (loading) return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
if (!data) return <Card><Card.Body><EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte de compras." /></Card.Body></Card>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-theme-muted">Periodo {data.start} a {data.end}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
<Button variant="outline" size="sm" onClick={exportar}><Download size={14} className="mr-1.5" />Exportar</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<Card><Card.Body><p className="text-xs text-theme-muted">Compras</p><p className="text-2xl font-heading text-theme-heading">{data.totales.compras}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Subtotal</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.subtotal)}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Impuestos</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.impuestos)}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Total</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.total)}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Saldo</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.saldo)}</p></Card.Body></Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<Card.Header><p className="font-medium text-theme-heading">Gasto por proveedor</p></Card.Header>
<Card.Body noPadding>
{data.por_proveedor.length === 0 ? (
<div className="p-6"><p className="text-sm text-theme-muted">Sin compras en el periodo.</p></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Proveedor</th>
<th className="text-right px-4 py-3">Compras</th>
<th className="text-right px-4 py-3">Total</th>
<th className="text-right px-4 py-3">Saldo</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.por_proveedor.map((p) => (
<tr key={p.partner_id}>
<td className="px-4 py-3 text-theme-heading">{p.proveedor}</td>
<td className="px-4 py-3 text-right tabular-nums">{p.compras}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.total)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.saldo)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
<Card>
<Card.Header><p className="font-medium text-theme-heading">Gasto por artículo</p></Card.Header>
<Card.Body noPadding>
{data.por_articulo.length === 0 ? (
<div className="p-6"><p className="text-sm text-theme-muted">Sin artículos comprados en el periodo.</p></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Artículo</th>
<th className="text-right px-4 py-3">Pedido</th>
<th className="text-right px-4 py-3">Recibido</th>
<th className="text-right px-4 py-3">Subtotal</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.por_articulo.map((a) => (
<tr key={a.item_id}>
<td className="px-4 py-3 text-theme-heading">{a.item}</td>
<td className="px-4 py-3 text-right tabular-nums">{a.cantidad_pedida}</td>
<td className="px-4 py-3 text-right tabular-nums">{a.cantidad_recibida}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.subtotal)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</div>
<Card>
<Card.Header><p className="font-medium text-theme-heading">Cuentas por pagar</p></Card.Header>
<Card.Body noPadding>
{data.cuentas_por_pagar.proveedores.length === 0 ? (
<div className="p-6"><p className="text-sm text-theme-muted">Sin saldos pendientes.</p></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Proveedor</th>
<th className="text-right px-4 py-3">Compras</th>
<th className="text-right px-4 py-3">Crédito</th>
<th className="text-right px-4 py-3">Saldo</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.cuentas_por_pagar.proveedores.map((p) => (
<tr key={p.partner_id}>
<td className="px-4 py-3 text-theme-heading">{p.proveedor}</td>
<td className="px-4 py-3 text-right tabular-nums">{p.compras}</td>
<td className="px-4 py-3 text-right tabular-nums">{p.dias_credito} días</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.saldo)}</td>
</tr>
))}
<tr className="bg-theme-bg font-medium">
<td className="px-4 py-3 text-theme-heading" colSpan={3}>Total por pagar</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(data.cuentas_por_pagar.total)}</td>
</tr>
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</div>
);
};
export default ComprasReport;

View File

@@ -0,0 +1,144 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { RefreshCw, Download, AlertTriangle } from 'lucide-react';
import { Card, Button, EmptyState, Skeleton, toast } from '../ui';
import { odooApi, type MargenReportResponse } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
import { useAuth } from '../../lib/auth';
import { exportToExcel } from '../../lib/exporter';
import { fmtMoney, fmtNum } from '../compras/shared';
const MargenReport: FC = () => {
const { start, end } = useReportRange('margen');
const { brand } = useAuth();
const [data, setData] = useState<MargenReportResponse | null>(null);
const [loading, setLoading] = useState(true);
const [soloAlertas, setSoloAlertas] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getMargenReport(start, end);
if (res.status === 'success') setData(res);
else toast.error(res.message || 'No se pudo cargar el reporte');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cargar el reporte de margen');
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const exportar = async () => {
if (!data) return;
try {
await exportToExcel({
filename: `reporte-margen-${data.start}_${data.end}`,
sheetName: 'Margen',
title: `Reporte de margen · ${brand.nombre}`,
subtitle: `Periodo ${data.start} a ${data.end}`,
columns: [
{ header: 'Artículo', key: 'item' },
{ header: 'SKU', key: 'sku' },
{ header: 'Precio', key: 'precio', format: 'currency' },
{ header: 'Costo promedio', key: 'costo_promedio', format: 'currency' },
{ header: 'Margen unitario', key: 'margen_unitario', format: 'currency' },
{ header: 'Margen %', key: 'margen_pct', format: 'number' },
{ header: 'Unidades vendidas', key: 'unidades_vendidas', format: 'number' },
{ header: 'Ingresos', key: 'ingresos', format: 'currency' },
{ header: 'Costo de ventas', key: 'costo_ventas', format: 'currency' },
{ header: 'Margen total', key: 'margen_total', format: 'currency' },
],
rows: data.articulos as unknown as Record<string, unknown>[],
totals: {
item: 'Totales', sku: '', precio: '', costo_promedio: '', margen_unitario: '',
margen_pct: data.totales.margen_pct, unidades_vendidas: '',
ingresos: data.totales.ingresos, costo_ventas: data.totales.costo_ventas,
margen_total: data.totales.margen,
},
});
toast.success('Excel descargado');
} catch {
toast.error('No se pudo exportar');
}
};
if (loading) return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
if (!data) return <Card><Card.Body><EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte de margen." /></Card.Body></Card>;
const filas = soloAlertas ? data.articulos.filter((a) => a.alerta) : data.articulos;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-theme-muted">Periodo {data.start} a {data.end}</p>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm text-theme-muted">
<input type="checkbox" checked={soloAlertas} onChange={(e) => setSoloAlertas(e.target.checked)} />
Solo alertas (&lt; {data.margen_minimo_alerta}%)
</label>
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
<Button variant="outline" size="sm" onClick={exportar}><Download size={14} className="mr-1.5" />Exportar</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card><Card.Body><p className="text-xs text-theme-muted">Ingresos por artículos</p><p className="text-xl font-heading text-theme-heading">{fmtMoney(data.totales.ingresos)}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Costo de ventas</p><p className="text-xl font-heading text-theme-heading">{fmtMoney(data.totales.costo_ventas)}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Margen</p><p className="text-xl font-heading text-theme-heading">{fmtMoney(data.totales.margen)}</p><p className="text-[11px] text-theme-muted">{data.totales.margen_pct}%</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted flex items-center gap-1"><AlertTriangle size={13} />En alerta</p><p className="text-xl font-heading text-theme-heading">{data.totales.alertas}</p></Card.Body></Card>
</div>
<Card>
<Card.Header><p className="font-medium text-theme-heading">Margen por artículo</p></Card.Header>
<Card.Body noPadding>
{filas.length === 0 ? (
<div className="p-6"><p className="text-sm text-theme-muted">Sin artículos en el periodo.</p></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Artículo</th>
<th className="text-right px-4 py-3">Precio</th>
<th className="text-right px-4 py-3">Costo prom.</th>
<th className="text-right px-4 py-3">Margen unit.</th>
<th className="text-right px-4 py-3">Margen %</th>
<th className="text-right px-4 py-3">Vendidas</th>
<th className="text-right px-4 py-3">Ingresos</th>
<th className="text-right px-4 py-3">Margen total</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{filas.map((a) => (
<tr key={a.item_id} className={a.alerta ? 'bg-amber-50' : ''}>
<td className="px-4 py-3">
<p className="text-theme-heading">{a.item}</p>
{a.sku && <p className="text-[11px] text-theme-muted">{a.sku}</p>}
</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.precio)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.costo_promedio)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.margen_unitario)}</td>
<td className="px-4 py-3 text-right tabular-nums">{a.margen_pct.toFixed(1)}%</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtNum(a.unidades_vendidas)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.ingresos)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{fmtMoney(a.margen_total)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
<p className="text-[11px] text-theme-muted">
El margen usa el precio de venta sin impuesto menos el costo promedio del artículo. Si un
artículo no tiene costo promedio cargado, se usa su costo unitario.
</p>
</div>
);
};
export default MargenReport;

View File

@@ -46,6 +46,9 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/pagos': ['admin', 'recepcion', 'gerencia'],
'/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'],
// Compras (apagado por defecto: la sección solo se pinta con
// compras.activo=true). Se lista aquí para que canSee la reconozca.
'/compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/cortes': ['admin', 'recepcion', 'gerencia'],
'/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'],
@@ -56,6 +59,8 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=margen': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/configuracion': ['admin', 'gerencia'],
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT } from '../services/odoo';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT } from '../services/odoo';
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
@@ -12,6 +12,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
});
let actual: ClinicSettings = normalizar(null);

View File

@@ -23,6 +23,7 @@ export const MENU_ITEMS: MenuItem[] = [
{ label: 'Pagos', href: '/pagos' },
{ label: 'Monedero', href: '/monedero' },
{ label: 'Inventario', href: '/inventario' },
{ label: 'Compras', href: '/compras' },
{ label: 'Cortes de Caja', href: '/cortes' },
{ label: 'Reportes', href: '/reportes' },
{ label: 'Comisiones', href: '/reportes?tab=comisiones' },

View File

@@ -20,6 +20,8 @@ export const PERM_KEYS: string[] = [
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
'membresias.ver', 'membresias.crear', 'membresias.cobrar', 'membresias.editar',
'membresias.cancelar',
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
];
@@ -46,6 +48,8 @@ const READ_PERM_KEYS = new Set([
'notificaciones.ver',
'formularios.ver',
'membresias.ver',
'compras.ver',
'proveedores.ver',
]);
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
@@ -71,6 +75,11 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'membresias.ver': ALL, 'membresias.crear': ['admin', 'recepcion', 'gerencia'],
'membresias.cobrar': ['admin', 'recepcion', 'gerencia'], 'membresias.editar': AG,
'membresias.cancelar': AG,
'compras.ver': ALL, 'compras.crear': ['admin', 'recepcion', 'gerencia'],
'compras.recibir': ['admin', 'recepcion', 'gerencia'],
'compras.pagar': ['admin', 'recepcion', 'gerencia'],
'compras.editar': AG, 'compras.cancelar': AG,
'proveedores.ver': ALL, 'proveedores.editar': AG,
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
};

View File

@@ -0,0 +1,359 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Plus, Search, RefreshCw, Eye, Pencil, Send, PackageCheck, CreditCard, Ban, Download, Truck } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, Modal, PageHeader, EmptyState, toast } from '../components/ui';
import { odooApi, type Compra, type CompraResumen } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { exportToExcel } from '../lib/exporter';
import { ESTADO_META, fmtMoney } from '../components/compras/shared';
import CompraFormModal from '../components/compras/CompraFormModal';
import CompraDetalleModal from '../components/compras/CompraDetalleModal';
import RecepcionModal from '../components/compras/RecepcionModal';
import PagoCompraModal from '../components/compras/PagoCompraModal';
import CuentasPorPagarPanel from '../components/compras/CuentasPorPagarPanel';
import ProveedoresPanel from '../components/compras/ProveedoresPanel';
const TABS = [
{ key: 'ordenes', label: 'Órdenes de compra' },
{ key: 'cuentas', label: 'Cuentas por pagar' },
{ key: 'proveedores', label: 'Proveedores' },
] as const;
const CHIPS = [
{ key: '', label: 'Todas' },
{ key: 'borrador', label: 'Borradores' },
{ key: 'enviada', label: 'Enviadas' },
{ key: 'por_recibir', label: 'Por recibir' },
{ key: 'por_pagar', label: 'Cuentas por pagar' },
];
const Compras: FC = () => {
const { can, brand } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get('tab') || 'ordenes') as typeof TABS[number]['key'];
const setTab = (t: string) => setSearchParams({ tab: t });
const [rows, setRows] = useState<Compra[]>([]);
const [resumen, setResumen] = useState<CompraResumen | null>(null);
const [loading, setLoading] = useState(true);
const [estado, setEstado] = useState('');
const [q, setQ] = useState('');
const [formOpen, setFormOpen] = useState(false);
const [formCompra, setFormCompra] = useState<Compra | null>(null);
const [detalle, setDetalle] = useState<Compra | null>(null);
const [detalleOpen, setDetalleOpen] = useState(false);
const [recepcion, setRecepcion] = useState<Compra | null>(null);
const [pago, setPago] = useState<Compra | null>(null);
const [cancelar, setCancelar] = useState<Compra | null>(null);
const [cancelMotivo, setCancelMotivo] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
if (tab !== 'ordenes') return;
setLoading(true);
try {
const res = await odooApi.getCompras({
...(estado ? { estado } : {}),
...(q ? { q } : {}),
page_size: 100,
});
setRows(res.compras || []);
setResumen(res.resumen || null);
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudieron cargar las compras');
} finally {
setLoading(false);
}
}, [tab, estado, q]);
useEffect(() => { load(); }, [load]);
const abrirDetalle = async (c: Compra) => {
try {
const res = await odooApi.getCompra(c.id);
if (res.status === 'success') { setDetalle(res.compra); setDetalleOpen(true); }
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo abrir la compra');
}
};
const enviar = async (c: Compra) => {
try {
await odooApi.enviarCompra(c.id);
toast.success('Compra enviada');
load();
if (detalle?.id === c.id) setDetalleOpen(false);
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo enviar');
}
};
const confirmarCancelar = async () => {
if (!cancelar) return;
setBusy(true);
try {
await odooApi.cancelarCompra(cancelar.id, cancelMotivo);
toast.success('Compra cancelada');
setCancelar(null);
setCancelMotivo('');
load();
if (detalle?.id === cancelar.id) setDetalleOpen(false);
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo cancelar');
} finally {
setBusy(false);
}
};
const exportar = async () => {
try {
await exportToExcel({
filename: `compras-${new Date().toISOString().slice(0, 10)}`,
sheetName: 'Compras',
title: `Compras · ${brand.nombre}`,
subtitle: `Filtro: ${CHIPS.find((c) => c.key === estado)?.label || 'Todas'}`,
columns: [
{ header: 'Folio', key: 'folio' },
{ header: 'Proveedor', key: 'proveedor' },
{ header: 'Fecha', key: 'fecha', format: 'date' },
{ header: 'Estado', key: 'estado_label' },
{ header: 'Moneda', key: 'moneda' },
{ header: 'Total', key: 'total', format: 'currency' },
{ header: 'Pagado', key: 'pagado', format: 'currency' },
{ header: 'Saldo', key: 'saldo', format: 'currency' },
],
rows: rows as unknown as Record<string, unknown>[],
totals: {
folio: '', proveedor: 'Totales', fecha: '', estado_label: '', moneda: '',
total: rows.reduce((s, r) => s + r.total, 0),
pagado: rows.reduce((s, r) => s + r.pagado, 0),
saldo: rows.reduce((s, r) => s + r.saldo, 0),
},
});
toast.success('Excel descargado');
} catch {
toast.error('No se pudo exportar');
}
};
return (
<Layout title="Compras" subtitle="Proveedores, órdenes, recepciones y cuentas por pagar">
<PageHeader title="Compras" subtitle="Órdenes de compra, recepción de mercancía y cuentas por pagar">
{tab === 'ordenes' && can('compras.crear') && (
<Button onClick={() => { setFormCompra(null); setFormOpen(true); }}>
<Plus size={16} className="mr-1.5" />Nueva compra
</Button>
)}
{tab === 'ordenes' && can('reportes.exportar') && (
<Button variant="outline" onClick={exportar} disabled={rows.length === 0}>
<Download size={16} className="mr-1.5" />Exportar
</Button>
)}
</PageHeader>
<div className="flex flex-wrap gap-1.5 mb-6">
{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 === 'cuentas' ? (
<CuentasPorPagarPanel />
) : tab === 'proveedores' ? (
<ProveedoresPanel />
) : (
<div className="space-y-4">
{resumen && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card><Card.Body>
<p className="text-xs text-theme-muted">Gasto del mes</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(resumen.gasto_mes)}</p>
<p className="text-[11px] text-theme-muted">{resumen.compras_mes} compra(s)</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Abiertas (por recibir)</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{resumen.abiertas}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Compras con saldo</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{resumen.por_pagar}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Saldo por pagar</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(resumen.saldo_total)}</p>
</Card.Body></Card>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px]">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input className="pl-9" placeholder="Buscar por folio, proveedor o referencia" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
</div>
<div className="flex flex-wrap gap-1.5">
{CHIPS.map((c) => (
<button
key={c.key || 'todas'}
type="button"
onClick={() => setEstado(c.key)}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
estado === c.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-bg text-theme-muted hover:text-theme-heading'
}`}
>
{c.label}
</button>
))}
</div>
<Card>
<Card.Body noPadding>
{loading ? (
<div className="p-6 text-sm text-theme-muted">Cargando compras…</div>
) : rows.length === 0 ? (
<div className="p-6">
<EmptyState title="Sin compras" subtitle="Crea una orden de compra para comenzar." icon={<Truck size={28} />} />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Folio</th>
<th className="text-left px-4 py-3">Proveedor</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Fecha</th>
<th className="text-right px-4 py-3">Total</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">Pagado</th>
<th className="text-right px-4 py-3">Saldo</th>
<th className="text-left px-4 py-3">Estado</th>
<th className="text-right px-4 py-3">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{rows.map((c) => {
const meta = ESTADO_META[c.estado];
return (
<tr key={c.id}>
<td className="px-4 py-3 text-theme-heading font-medium">{c.folio}</td>
<td className="px-4 py-3 text-theme-heading">{c.proveedor}</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{c.fecha}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(c.total, c.moneda)}</td>
<td className="px-4 py-3 text-right tabular-nums hidden lg:table-cell text-theme-muted">{fmtMoney(c.pagado, c.moneda)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{fmtMoney(c.saldo, c.moneda)}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.cls}`}>{meta.label}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button type="button" title="Ver detalle" onClick={() => abrirDetalle(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Eye size={15} /></button>
{can('compras.editar') && ['borrador', 'enviada'].includes(c.estado) && (
<button type="button" title="Editar" onClick={() => { setFormCompra(c); setFormOpen(true); }} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Pencil size={15} /></button>
)}
{can('compras.crear') && c.estado === 'borrador' && (
<button type="button" title="Enviar" onClick={() => enviar(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Send size={15} /></button>
)}
{can('compras.recibir') && c.puede_recibir && (
<button type="button" title="Recibir" onClick={() => setRecepcion(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><PackageCheck size={15} /></button>
)}
{can('compras.pagar') && c.estado !== 'cancelada' && c.saldo > 0 && (
<button type="button" title="Pagar" onClick={() => setPago(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><CreditCard size={15} /></button>
)}
{can('compras.cancelar') && c.puede_cancelar && (
<button type="button" title="Cancelar" onClick={() => { setCancelar(c); setCancelMotivo(''); }} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"><Ban size={15} /></button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</div>
)}
<CompraFormModal
isOpen={formOpen}
onClose={() => setFormOpen(false)}
compra={formCompra}
onDone={() => { load(); if (detalleOpen) setDetalleOpen(false); }}
/>
<CompraDetalleModal
isOpen={detalleOpen}
onClose={() => setDetalleOpen(false)}
compra={detalle}
puedeEditar={can('compras.editar')}
puedeRecibir={can('compras.recibir')}
puedePagar={can('compras.pagar')}
puedeCancelar={can('compras.cancelar')}
puedeCrear={can('compras.crear')}
onEdit={(c) => { setDetalleOpen(false); setFormCompra(c); setFormOpen(true); }}
onEnviar={(c) => enviar(c)}
onRecibir={(c) => setRecepcion(c)}
onPagar={(c) => setPago(c)}
onCancelar={(c) => { setCancelar(c); setCancelMotivo(''); }}
/>
<RecepcionModal
isOpen={!!recepcion}
onClose={() => setRecepcion(null)}
compra={recepcion}
onDone={() => { load(); }}
/>
<PagoCompraModal
isOpen={!!pago}
onClose={() => setPago(null)}
compraId={pago?.id ?? null}
folio={pago?.folio || ''}
saldo={pago?.saldo || 0}
moneda={pago?.moneda || 'MXN'}
onDone={() => load()}
/>
<Modal
isOpen={!!cancelar}
onClose={() => setCancelar(null)}
title={`Cancelar compra ${cancelar?.folio || ''}`}
footer={(
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCancelar(null)}>Volver</Button>
<Button variant="danger" onClick={confirmarCancelar} loading={busy}>Cancelar compra</Button>
</div>
)}
>
<p className="text-sm text-theme-muted">
Solo se pueden cancelar compras <strong>sin recepciones</strong>. Si la mercancía ya
entró, registra una baja o un ajuste de inventario.
</p>
<div className="mt-3">
<Input label="Motivo (opcional)" value={cancelMotivo} onChange={(e) => setCancelMotivo(e.target.value)} />
</div>
</Modal>
</Layout>
);
};
export default Compras;

View File

@@ -19,6 +19,7 @@ import NotificacionesPanel from '../components/NotificacionesPanel';
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
import FormulariosPanel from '../components/FormulariosPanel';
import MembresiasPanel from '../components/MembresiasPanel';
import ComprasPanel from '../components/ComprasPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth';
@@ -34,6 +35,7 @@ const TABS = [
{ key: 'categorias', label: 'Categorías' },
{ key: 'recursos', label: 'Recursos' },
{ key: 'membresias', label: 'Membresías' },
{ key: 'compras', label: 'Compras' },
{ key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'roles', label: 'Roles' },
@@ -44,7 +46,7 @@ const TABS = [
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'terminologia', 'usuarios', 'roles', 'tickets'];
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'terminologia', 'usuarios', 'roles', 'tickets'];
const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -69,6 +71,7 @@ const Configuracion: FC = () => {
notif_enviadas: 'notificaciones.ver',
formularios: 'formularios.ver',
membresias: 'membresias.editar',
compras: 'compras.editar',
recursos: 'recursos.editar',
};
const tabsVisibles = useMemo(
@@ -397,6 +400,12 @@ const Configuracion: FC = () => {
<MembresiasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'compras' ? (
<Card>
<Card.Body>
<ComprasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'terminologia' ? (
<Card>
<Card.Body>

View File

@@ -11,6 +11,7 @@ import { Card, Button, Input, Select, TextArea, Modal, PageHeader, EmptyState, S
import { odooApi } from '../services/odoo';
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary, InventoryConteo } from '../services/odoo';
import { useClinicSettings } from '../lib/clinic-settings';
import { useAuth } from '../lib/auth';
const TABS = [
{ key: 'items', label: 'Items' },
@@ -70,6 +71,9 @@ const Inventario: FC = () => {
const tab = searchParams.get('tab') || 'items';
const setTab = (t: string) => setSearchParams({ tab: t });
const impuestos = useClinicSettings().impuestos;
const comprasCfg = useClinicSettings().compras;
const { can } = useAuth();
const puedeEditarCosto = comprasCfg.activo && can('inventario.costos');
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);
@@ -182,6 +186,7 @@ const Inventario: FC = () => {
setEditItem(it);
setEditForm({ name: it.name, sku: it.sku, category: it.category, unit: it.unit,
cost: it.cost, price: it.price, qty_min: it.qty_min, qty_optimal: it.qty_optimal, notes: it.notes,
costo_promedio: it.costo_promedio ?? 0, ultimo_costo: it.ultimo_costo ?? 0,
tax_use_default: it.tax_use_default !== false, tax_rate: it.tax_rate ?? 0 });
};
@@ -414,7 +419,14 @@ const Inventario: FC = () => {
<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>
{comprasCfg.activo && (
<>
<th className="text-right px-4 py-3">Costo prom.</th>
<th className="text-right px-4 py-3">Último</th>
</>
)}
<th className="text-right px-4 py-3">Precio venta</th>
{comprasCfg.activo && <th className="text-right px-4 py-3">Margen</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>
@@ -435,7 +447,21 @@ const Inventario: FC = () => {
<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>
{comprasCfg.activo && (
<>
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.costo_promedio ?? 0)}</td>
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.ultimo_costo ?? 0)}</td>
</>
)}
<td className="px-4 py-3 text-right font-medium text-theme-heading">{fmtMoney(it.price ?? 0)}</td>
{comprasCfg.activo && (
<td className="px-4 py-3 text-right">
<span className={(it.margen ?? 0) < 0 ? 'text-rose-600' : 'text-theme-muted'}>
{fmtMoney(it.margen ?? 0)}
<span className="text-[11px] text-theme-muted"> ({(it.margen_pct ?? 0).toFixed(0)}%)</span>
</span>
</td>
)}
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.inventory_value)}</td>
<td className="px-4 py-3 text-theme-muted">{it.expiry_date || '—'}</td>
<td className="px-4 py-3">
@@ -537,6 +563,28 @@ const Inventario: FC = () => {
/>
)}
<Input label="Costo unitario" type="number" min="0" step="0.01" value={String(editForm.cost ?? 0)} onChange={(e) => setEditForm({ ...editForm, cost: parseFloat(e.target.value) || 0 })} />
{puedeEditarCosto && (
<>
<Input
label="Costo promedio (ajuste manual)"
type="number"
step="0.01"
value={String(editForm.costo_promedio ?? 0)}
onChange={(e) => setEditForm({ ...editForm, costo_promedio: parseFloat(e.target.value) || 0 })}
/>
<Input
label="Último costo"
type="number"
step="0.01"
value={String(editForm.ultimo_costo ?? 0)}
onChange={(e) => setEditForm({ ...editForm, ultimo_costo: parseFloat(e.target.value) || 0 })}
/>
<p className="sm:col-span-2 text-xs text-sky-700 bg-sky-50 rounded-lg p-2">
El costo promedio se recalcula solo con cada recepción de compra. Editarlo aquí es
un ajuste manual de inventario.
</p>
</>
)}
<Input label="SKU / Código" value={editForm.sku || ''} onChange={(e) => setEditForm({ ...editForm, sku: e.target.value })} />
<Input label="Línea / Categoría" value={editForm.category || ''} onChange={(e) => setEditForm({ ...editForm, category: e.target.value })} />
<Input label="Unidad" value={editForm.unit || ''} onChange={(e) => setEditForm({ ...editForm, unit: e.target.value })} />

View File

@@ -35,6 +35,8 @@ import ConcentradoReport from '../components/reportes/ConcentradoReport';
import RecomendacionesReport from '../components/reportes/RecomendacionesReport';
import KpisReport from '../components/reportes/KpisReport';
import RecursosReport from '../components/reportes/RecursosReport';
import ComprasReport from '../components/reportes/ComprasReport';
import MargenReport from '../components/reportes/MargenReport';
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { CommissionRow } from '../services/odoo';
@@ -104,6 +106,8 @@ const FAMILIAS: ReportFamily[] = [
{ key: 'horas-agenda', label: 'Horas agenda', desc: 'Citas atendidas por hora y médico' },
{ key: 'recursos', label: 'Recursos', desc: 'Ocupación de cabinas, salas y equipos' },
{ key: 'inventario', label: 'Inventario', desc: 'Existencias, mínimos y caducidades' },
{ key: 'compras', label: 'Compras', desc: 'Gasto por periodo, proveedor y artículo' },
{ key: 'margen', label: 'Margen', desc: 'Margen por artículo: precio menos costo promedio' },
{ key: 'pagos-servicios', label: 'Pagos · Servicios', desc: 'Cobros agrupados por servicio' },
{ key: 'concentrado', label: 'Concentrado', desc: 'Todo el periodo en una tabla' },
],
@@ -150,7 +154,7 @@ interface CashReport {
const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { hasRole, canSee, can, brand } = useAuth();
const { membresias: membresiasCfg, recursos: recursosCfg } = useClinicSettings();
const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg } = useClinicSettings();
const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
@@ -160,8 +164,9 @@ const ReportesContent: FC = () => {
const puedeTab = useCallback((k: string) => {
if (k === 'membresias' && !membresiasCfg.activo) return false;
if (k === 'recursos' && !recursosCfg.activo) return false;
if ((k === 'compras' || k === 'margen') && !comprasCfg.activo) return false;
return canSee(`/reportes?tab=${k}`);
}, [canSee, membresiasCfg.activo, recursosCfg.activo]);
}, [canSee, membresiasCfg.activo, recursosCfg.activo, comprasCfg.activo]);
const familiasVisibles = useMemo(
() =>
FAMILIAS
@@ -465,6 +470,10 @@ const ReportesContent: FC = () => {
<ImpuestosReport />
) : tab === 'inventario' ? (
<InventarioReport />
) : tab === 'compras' ? (
<ComprasReport />
) : tab === 'margen' ? (
<MargenReport />
) : tab === 'adeudos' ? (
<AdeudosReport />
) : tab === 'comisiones' ? (

View File

@@ -816,6 +816,11 @@ export interface InventoryItem {
qty_min: number;
cost: number;
price?: number;
/** Costo promedio ponderado de compras y último costo recibido (módulo Compras). */
costo_promedio?: number;
ultimo_costo?: number;
margen?: number;
margen_pct?: number;
inventory_value: number;
expiry_date: string | null;
last_count_date: string | null;
@@ -1069,6 +1074,7 @@ export interface ClinicSettings {
notificaciones: NotificacionesSettings;
membresias: MembresiasSettings;
recursos: RecursosSettings;
compras: ComprasSettings;
}
// ---- Reservas en línea (portal público de auto-agendado) ----
@@ -1234,6 +1240,238 @@ export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
dias_gracia: 5,
};
// ---- Compras y proveedores ----
export interface ComprasSettings {
/** Enciende TODO el módulo: menú, recepciones, costo promedio y reportes. */
activo: boolean;
/** Al recibir, recalcula el costo promedio ponderado del artículo. */
actualizar_costo_promedio: boolean;
/** Permite recibir menos de lo pedido y completar en recepciones posteriores. */
permitir_recepcion_parcial: boolean;
/** Exige enviar (autorizar) la orden antes de poder recibirla. */
requiere_autorizacion: boolean;
/** Días de crédito por defecto de un proveedor nuevo. */
dias_credito_default: number;
/** Margen (%) bajo el cual el reporte de margen avisa. */
margen_minimo_alerta: number;
/** Permite registrar una recepción sin orden de compra. */
permitir_compras_sin_orden: boolean;
}
export const COMPRAS_DEFAULT: ComprasSettings = {
activo: false,
actualizar_costo_promedio: true,
permitir_recepcion_parcial: true,
requiere_autorizacion: false,
dias_credito_default: 0,
margen_minimo_alerta: 0,
permitir_compras_sin_orden: false,
};
export type EstadoCompra = 'borrador' | 'enviada' | 'recibida_parcial' | 'recibida' | 'cancelada';
export type MonedaCompra = 'MXN' | 'USD';
export type MetodoPagoCompra = 'efectivo' | 'transferencia' | 'tarjeta' | 'otro';
export interface Proveedor {
id: number;
name: string;
email: string;
phone: string;
mobile: string;
vat: string;
street: string;
city: string;
supplier_rank: number;
activo: boolean;
dias_credito: number;
contacto: string;
banco: string;
cuenta_pago: string;
notas: string;
compras_count: number;
saldo: number;
}
export interface CompraLinea {
id: number;
item_id: number;
item: string;
sku: string;
unit: string;
kind: 'producto' | 'consumible';
cantidad_pedida: number;
cantidad_recibida: number;
cantidad_pendiente: number;
costo_unitario: number;
descuento: number;
subtotal: number;
impuesto: number;
total: number;
lote: string;
fecha_caducidad: string | null;
notas: string;
}
export interface CompraRecepcionLinea {
id: number;
item_id: number;
item: string;
cantidad: number;
costo_unitario: number;
lote: string;
fecha_caducidad: string | null;
move_id: number | null;
}
export interface CompraRecepcion {
id: number;
fecha: string | null;
usuario: string;
notas: string;
total_unidades: number;
total_costo: number;
lineas: CompraRecepcionLinea[];
}
export interface CompraPago {
id: number;
fecha: string | null;
monto: number;
moneda: MonedaCompra;
metodo: MetodoPagoCompra;
metodo_label: string;
referencia: string;
notas: string;
usuario: string;
monto_en_compra: number;
}
export interface Compra {
id: number;
folio: string;
partner_id: number;
proveedor: string;
fecha: string | null;
fecha_entrega_esperada: string | null;
estado: EstadoCompra;
estado_label: string;
moneda: MonedaCompra;
subtotal: number;
descuento: number;
impuestos: number;
total: number;
pagado: number;
saldo: number;
sucursal_id: number | null;
sucursal: string;
referencia_factura: string;
notas: string;
creado_por_login: string;
recibida_at: string | null;
cancel_reason: string;
lineas_count: number;
recepciones_count: number;
tiene_recepciones: boolean;
total_pedido: number;
total_recibido: number;
puede_recibir: boolean;
puede_cancelar: boolean;
lineas?: CompraLinea[];
recepciones?: CompraRecepcion[];
pagos?: CompraPago[];
}
export interface CompraInput {
partner_id?: number;
fecha?: string;
fecha_entrega_esperada?: string | null;
moneda?: MonedaCompra;
descuento?: number;
notas?: string;
referencia_factura?: string;
sucursal_id?: number | null;
lineas?: {
item_id: number;
cantidad_pedida: number;
costo_unitario?: number;
descuento?: number;
lote?: string;
fecha_caducidad?: string | null;
notas?: string;
}[];
}
export interface CompraResumen {
abiertas: number;
por_pagar: number;
saldo_total: number;
compras_mes: number;
gasto_mes: number;
borradores: number;
enviadas: number;
}
export interface CuentaPorPagar {
partner_id: number;
proveedor: string;
dias_credito: number;
saldo: number;
compras: number;
buckets: Record<string, number>;
detalle: {
id: number; folio: string; fecha: string | null; fecha_vencimiento: string | null;
dias: number; bucket: string; total: number; pagado: number; saldo: number;
moneda: MonedaCompra;
}[];
}
export interface CuentasPorPagarResponse {
status: string;
proveedores: CuentaPorPagar[];
buckets: { key: string; label: string }[];
totales: { saldo: number; por_bucket: Record<string, number>; proveedores: number; compras: number };
}
export interface ComprasReportResponse {
status: string;
start: string;
end: string;
totales: { compras: number; subtotal: number; impuestos: number; total: number; pagado: number; saldo: number };
por_proveedor: { partner_id: number; proveedor: string; compras: number; total: number; pagado: number; saldo: number }[];
por_articulo: { item_id: number; item: string; sku: string; category: string; unidad: string;
cantidad_pedida: number; cantidad_recibida: number; subtotal: number }[];
por_mes: { mes: string; total: number }[];
cuentas_por_pagar: { total: number; proveedores: { partner_id: number; proveedor: string; saldo: number; compras: number; dias_credito: number }[] };
message?: string;
}
export interface MargenReportFila {
item_id: number;
item: string;
sku: string;
category: string;
unidad: string;
precio: number;
costo_promedio: number;
margen_unitario: number;
margen_pct: number;
unidades_vendidas: number;
ingresos: number;
costo_ventas: number;
margen_total: number;
alerta: boolean;
}
export interface MargenReportResponse {
status: string;
start: string;
end: string;
margen_minimo_alerta: number;
articulos: MargenReportFila[];
totales: { ingresos: number; costo_ventas: number; margen: number; margen_pct: number; alertas: number };
message?: string;
}
export type PeriodicidadMembresia = 'semanal' | 'mensual' | 'trimestral' | 'semestral' | 'anual';
export type EstadoMembresia = 'activa' | 'congelada' | 'vencida' | 'pausada' | 'cancelada';
export type EstadoPeriodoMembresia = 'pendiente' | 'pagado' | 'vencido' | 'condonado';
@@ -1636,6 +1874,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
notificaciones: NOTIFICACIONES_DEFAULT,
membresias: MEMBRESIAS_DEFAULT,
recursos: RECURSOS_DEFAULT,
compras: COMPRAS_DEFAULT,
};
export interface FrontendUser {
@@ -2330,6 +2569,96 @@ export const odooApi = {
return data;
},
// ---- Compras y proveedores ----
// Con `compras.activo=false` (default) el backend responde 403 y estas
// llamadas nunca se hacen desde la UI (la sección no se monta).
async getProveedores(opts?: { includeInactive?: boolean; q?: string; page?: number; page_size?: number }): Promise<{
status: string; proveedores: Proveedor[]; total: number; page: number; page_size: number; message?: string;
}> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/proveedores`, {
params: {
...(opts?.includeInactive ? { include_inactive: 1 } : {}),
...(opts?.q ? { q: opts.q } : {}),
...(opts?.page ? { page: opts.page } : {}),
...(opts?.page_size ? { page_size: opts.page_size } : {}),
},
});
return data;
},
async createProveedor(payload: Partial<Proveedor> & { name: string }): Promise<{ status: string; proveedor: Proveedor; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/proveedores`, payload);
return data;
},
async updateProveedor(id: number, payload: Partial<Proveedor>): Promise<{ status: string; proveedor: Proveedor; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/proveedores/${id}`, payload);
return data;
},
async deleteProveedor(id: number): Promise<{ status: string; proveedor?: Proveedor; message?: string; desactivar?: boolean }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/proveedores/${id}`);
return data;
},
async getCompras(params?: { estado?: string; partner_id?: number; start?: string; end?: string; q?: string; page?: number; page_size?: number }): Promise<{
status: string; compras: Compra[]; total: number; page: number; page_size: number; total_pages: number;
resumen: CompraResumen; message?: string;
}> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/compras`, { params });
return data;
},
async getCompra(id: number): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}`);
return data;
},
async crearOrdenCompra(payload: CompraInput): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras`, payload);
return data;
},
async updateCompra(id: number, payload: CompraInput): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}`, payload);
return data;
},
async enviarCompra(id: number): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/enviar`, {});
return data;
},
async recibirCompra(id: number, payload: { notas?: string; lineas: { linea_id: number; cantidad: number; lote?: string; fecha_caducidad?: string | null; costo_unitario?: number }[] }): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/recibir`, payload);
return data;
},
async cancelarCompra(id: number, motivo?: string): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/cancelar`, { motivo });
return data;
},
async pagarCompra(id: number, payload: { monto: number; moneda?: MonedaCompra; metodo?: MetodoPagoCompra; referencia?: string; notas?: string }): Promise<{ status: string; compra: Compra; pago: CompraPago; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/pagar`, payload);
return data;
},
async getCuentasPorPagar(params?: { partner_id?: number }): Promise<CuentasPorPagarResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/compras/cuentas_por_pagar`, { params });
return data;
},
async getComprasReport(start: string, end: string): Promise<ComprasReportResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/compras`, { params: { start, end } });
return data;
},
async getMargenReport(start: string, end: string): Promise<MargenReportResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/margen`, { params: { start, end } });
return data;
},
// ---- Membresías y suscripciones ----
// Con `membresias.activo=false` (default) el backend responde 403 y estas
// llamadas nunca se hacen desde la UI (la sección no se monta).