fase 3.5: licenciamiento por plan (clave firmada, planes basico/profesional/enterprise/custom, modulos incluidos y limites de usuarios/sucursales/citas/almacenamiento, modo aviso o bloqueo, panel de licencia) + API keys gestionables con alcances y revocacion (arregla la API publica que estaba rota) + webhooks salientes firmados con outbox, reintentos y bitacora; herramientas de vendedor y docs

This commit is contained in:
2026-10-11 19:53:44 +00:00
parent ab9d84cb67
commit 3ed0d327d2
30 changed files with 3705 additions and 89 deletions

View File

@@ -0,0 +1,230 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Copy, KeyRound, Plus, RefreshCw, ShieldOff, Pencil } from 'lucide-react';
import { Button, Card, Input, Modal, TextArea, toast } from './ui';
import { odooApi, type ApiKey } from '../services/odoo';
// Pestaña Configuración -> API (solo administración).
const ESTADO_BADGE: Record<string, string> = {
activa: 'bg-emerald-100 text-emerald-700',
vencida: 'bg-amber-100 text-amber-700',
revocada: 'bg-rose-100 text-rose-700',
};
const ENDPOINTS_DOC = [
'available_slots → citas.read',
'create_appointment → citas.write',
'cancel_appointment → citas.write',
'patient_balance → pacientes.read',
'wallet_redeem → monedero.write',
'services → servicios.read',
];
const ApiKeysPanel: FC = () => {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [scopes, setScopes] = useState<{ clave: string; label: string }[]>([]);
const [, setCargando] = useState(true);
const [nuevaClave, setNuevaClave] = useState<string | null>(null);
const [mostrarCrear, setMostrarCrear] = useState(false);
const [editando, setEditando] = useState<ApiKey | null>(null);
// Formulario de creación
const [nombre, setNombre] = useState('');
const [alcances, setAlcances] = useState<string[]>([]);
const [vence, setVence] = useState('');
const [guardando, setGuardando] = useState(false);
const cargar = useCallback(async () => {
try {
const res = await odooApi.listApiKeys();
if (res.status === 'success') { setKeys(res.api_keys); setScopes(res.scopes); }
} catch {
toast.error('No se pudieron cargar las claves de API');
} finally {
setCargando(false);
}
}, []);
useEffect(() => { cargar(); }, [cargar]);
const crear = async () => {
if (!nombre.trim()) { toast.error('Ponle un nombre a la clave'); return; }
setGuardando(true);
try {
const res = await odooApi.createApiKey({ name: nombre.trim(), scopes: alcances, vence });
setNuevaClave(res.clave);
setMostrarCrear(false);
setNombre(''); setAlcances([]); setVence('');
await cargar();
} catch {
toast.error('No se pudo crear la clave');
} finally {
setGuardando(false);
}
};
const revocar = async (k: ApiKey) => {
if (!window.confirm(`¿Revocar la clave "${k.name}"? Dejará de funcionar de inmediato.`)) return;
try {
await odooApi.revocarApiKey(k.id);
toast.success('Clave revocada');
await cargar();
} catch {
toast.error('No se pudo revocar');
}
};
const guardarEdicion = async () => {
if (!editando) return;
try {
await odooApi.updateApiKey(editando.id, {
name: editando.name, scopes: editando.scopes, activo: editando.activo, vence: editando.vence || '', notas: editando.notas,
});
toast.success('Clave actualizada');
setEditando(null);
await cargar();
} catch {
toast.error('No se pudo actualizar');
}
};
const copiar = async (texto: string) => {
try { await navigator.clipboard.writeText(texto); toast.success('Copiado'); }
catch { toast.error('No se pudo copiar'); }
};
const toggleAlcance = (c: string) => setAlcances((prev) => prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]);
return (
<div className="space-y-5">
<Card>
<Card.Body>
<div className="flex items-center justify-between gap-3">
<h3 className="font-heading text-xl text-theme-heading flex items-center"><KeyRound size={19} className="mr-2" /> Claves de API</h3>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={cargar}><RefreshCw size={15} className="mr-1" /> Actualizar</Button>
<Button size="sm" onClick={() => setMostrarCrear(true)}><Plus size={15} className="mr-1" /> Nueva clave</Button>
</div>
</div>
<p className="mt-2 text-xs text-theme-muted">
Las claves de la API de terceros (<code>/skeen/api/v1/*</code>) se envían en
<code> Authorization: Bearer &lt;clave&gt;</code> o <code>X-API-Key</code>. La clave legacy
de Hermes sigue funcionando.
</p>
</Card.Body>
</Card>
<Card>
<Card.Body>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-2 text-xs text-theme-muted uppercase">Nombre</th>
<th className="text-left p-2 text-xs text-theme-muted uppercase">Prefijo</th>
<th className="text-left p-2 text-xs text-theme-muted uppercase">Alcances</th>
<th className="text-left p-2 text-xs text-theme-muted uppercase">Último uso</th>
<th className="text-right p-2 text-xs text-theme-muted uppercase">Usos</th>
<th className="text-left p-2 text-xs text-theme-muted uppercase">Estado</th>
<th className="text-right p-2 text-xs text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{keys.map((k) => (
<tr key={k.id}>
<td className="p-2 font-medium text-theme-heading">{k.name}</td>
<td className="p-2 font-mono text-xs text-theme-muted">{k.prefijo}…</td>
<td className="p-2 text-theme-muted">{k.scopes_label}</td>
<td className="p-2 text-theme-muted">{k.ultimo_uso || '—'}</td>
<td className="p-2 text-right text-theme-muted">{k.usos}</td>
<td className="p-2"><span className={`px-2 py-0.5 rounded-full text-xs ${ESTADO_BADGE[k.estado] || 'bg-theme-bg'}`}>{k.estado}</span></td>
<td className="p-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => setEditando({ ...k })}><Pencil size={14} /></Button>
{k.estado !== 'revocada' && (
<Button variant="ghost" size="sm" onClick={() => revocar(k)}><ShieldOff size={14} /></Button>
)}
</div>
</td>
</tr>
))}
{keys.length === 0 && (
<tr><td colSpan={7} className="p-4 text-center text-theme-muted">No hay claves de API.</td></tr>
)}
</tbody>
</table>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h4 className="font-heading text-lg text-theme-heading mb-2">Alcances por endpoint</h4>
<ul className="text-sm text-theme-muted list-disc pl-5 space-y-1">
{ENDPOINTS_DOC.map((e) => <li key={e}><code>{e}</code></li>)}
</ul>
<p className="mt-2 text-xs text-theme-muted">Una clave sin alcances definidos tiene acceso a todos.</p>
</Card.Body>
</Card>
<Modal isOpen={mostrarCrear} onClose={() => setMostrarCrear(false)} title="Nueva clave de API"
footer={<><Button variant="outline" onClick={() => setMostrarCrear(false)}>Cancelar</Button><Button onClick={crear} disabled={guardando}>{guardando ? 'Creando…' : 'Crear'}</Button></>}>
<div className="space-y-3">
<Input label="Nombre" value={nombre} onChange={(e) => setNombre(e.target.value)} placeholder="Integración Hermes" />
<Input label="Vence (opcional)" type="date" value={vence} onChange={(e) => setVence(e.target.value)} />
<div>
<p className="text-xs font-medium text-theme-muted mb-1.5">Alcances (vacío = todos)</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{scopes.map((s) => (
<label key={s.clave} className="flex items-center gap-2 text-sm text-theme-muted">
<input type="checkbox" checked={alcances.includes(s.clave)} onChange={() => toggleAlcance(s.clave)} className="h-4 w-4 rounded" />
{s.label}
</label>
))}
</div>
</div>
</div>
</Modal>
<Modal isOpen={!!nuevaClave} onClose={() => setNuevaClave(null)} title="Guarda tu clave ahora"
footer={<Button onClick={() => setNuevaClave(null)}>Listo</Button>}>
<p className="text-sm text-theme-muted mb-2">Esta clave se muestra una sola vez. Cópiala y guárdala en un lugar seguro.</p>
<div className="flex items-center gap-2">
<code className="flex-1 break-all rounded-lg bg-theme-bg px-3 py-2 text-sm">{nuevaClave}</code>
<Button variant="outline" size="sm" onClick={() => copiar(nuevaClave || '')}><Copy size={15} /></Button>
</div>
</Modal>
<Modal isOpen={!!editando} onClose={() => setEditando(null)} title="Editar clave"
footer={<><Button variant="outline" onClick={() => setEditando(null)}>Cancelar</Button><Button onClick={guardarEdicion}>Guardar</Button></>}>
{editando && (
<div className="space-y-3">
<Input label="Nombre" value={editando.name} onChange={(e) => setEditando({ ...editando, name: e.target.value })} />
<Input label="Vence (opcional)" type="date" value={editando.vence} onChange={(e) => setEditando({ ...editando, vence: e.target.value })} />
<label className="flex items-center gap-2 text-sm text-theme-muted">
<input type="checkbox" checked={editando.activo} onChange={(e) => setEditando({ ...editando, activo: e.target.checked })} className="h-4 w-4 rounded" />
Activa
</label>
<div>
<p className="text-xs font-medium text-theme-muted mb-1.5">Alcances</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{scopes.map((s) => (
<label key={s.clave} className="flex items-center gap-2 text-sm text-theme-muted">
<input type="checkbox" checked={editando.scopes.includes(s.clave)}
onChange={() => setEditando({ ...editando, scopes: editando.scopes.includes(s.clave) ? editando.scopes.filter((x) => x !== s.clave) : [...editando.scopes, s.clave] })}
className="h-4 w-4 rounded" />
{s.label}
</label>
))}
</div>
</div>
<TextArea label="Notas" rows={2} value={editando.notas} onChange={(e) => setEditando({ ...editando, notas: e.target.value })} />
</div>
)}
</Modal>
</div>
);
};
export default ApiKeysPanel;

View File

@@ -49,7 +49,7 @@ import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
import { odooApi, licenciaModuloIncluido } from '../services/odoo';
interface LayoutProps {
children: ReactNode;
@@ -122,7 +122,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
const topNavItems = useMemo(
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
[term]
@@ -144,8 +144,15 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
? { ...s, items: [...s.items, HOME_COMISIONES_ITEM] }
: s));
}
// Licencia en modo bloqueo sin WhatsApp: se ocultan el chat y la sección WACRM.
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
base = base
.filter((s) => s.label !== 'WACRM')
.map((s) => ({ ...s, items: s.items.filter((i) => i.href !== '/chat') }))
.filter((s) => s.items.length > 0);
}
return traducirMenu(base, term);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
const chatUnread = useChatUnread();

View File

@@ -0,0 +1,232 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { AlertTriangle, BadgeCheck, KeyRound, RefreshCw, ShieldOff, Trash2 } from 'lucide-react';
import { Button, Card, Select, TextArea, toast } from './ui';
import { odooApi, type LicenciaEstadoResponse } from '../services/odoo';
// Pestaña Configuración -> Licencia (solo administración).
const ESTADO_COLOR: Record<string, string> = {
ok: 'bg-emerald-500',
cerca: 'bg-amber-500',
excedido: 'bg-rose-500',
sin_limite: 'bg-theme-border-strong',
};
const LicenciaPanel: FC = () => {
const [estado, setEstado] = useState<LicenciaEstadoResponse | null>(null);
const [cargando, setCargando] = useState(true);
const [clave, setClave] = useState('');
const [validando, setValidando] = useState(false);
const [aplicando, setAplicando] = useState(false);
const [resultadoValidacion, setResultadoValidacion] = useState<{ valida: boolean; error: string | null } | null>(null);
const cargar = useCallback(async () => {
try {
const res = await odooApi.getLicencia();
if (res.status === 'success') setEstado(res);
} catch {
toast.error('No se pudo cargar el estado de la licencia');
} finally {
setCargando(false);
}
}, []);
useEffect(() => { cargar(); }, [cargar]);
const validar = async () => {
if (!clave.trim()) { toast.error('Pega una clave de licencia'); return; }
setValidando(true);
setResultadoValidacion(null);
try {
const res = await odooApi.validarLicencia(clave.trim());
setResultadoValidacion({ valida: res.valida, error: res.error });
if (res.valida) toast.success('La clave es válida');
else toast.error(res.error || 'Clave inválida');
} catch {
toast.error('No se pudo validar la clave');
} finally {
setValidando(false);
}
};
const aplicar = async () => {
if (!clave.trim()) { toast.error('Pega una clave de licencia'); return; }
setAplicando(true);
try {
const res = await odooApi.putLicencia({ clave: clave.trim(), modo: 'aviso' });
if (res.status === 'success') {
setEstado(res);
setClave('');
setResultadoValidacion(null);
toast.success('Licencia aplicada (modo aviso)');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo aplicar la licencia');
} finally {
setAplicando(false);
}
};
const quitar = async () => {
if (!window.confirm('¿Quitar la licencia? Todas las funciones vuelven a quedar habilitadas.')) return;
try {
const res = await odooApi.putLicencia({ clave: '' });
if (res.status === 'success') { setEstado(res); toast.success('Licencia retirada'); }
} catch {
toast.error('No se pudo quitar la licencia');
}
};
const cambiarModo = async (modo: 'aviso' | 'bloqueo') => {
try {
const res = await odooApi.putLicencia({ modo });
if (res.status === 'success') {
setEstado(res);
toast.success(modo === 'bloqueo' ? 'Modo bloqueo activado' : 'Modo aviso activado');
}
} catch {
toast.error('No se pudo cambiar el modo');
}
};
if (cargando) return <p className="text-sm text-theme-muted">Cargando licencia…</p>;
if (!estado) return <p className="text-sm text-theme-muted">No se pudo cargar la licencia.</p>;
const lic = estado.licencia;
return (
<div className="space-y-5">
<Card>
<Card.Body>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
{lic.aplicada ? <BadgeCheck size={20} className="text-emerald-600" /> : <ShieldOff size={20} className="text-theme-muted" />}
<h3 className="font-heading text-xl text-theme-heading">Licencia</h3>
</div>
<Button variant="ghost" size="sm" onClick={cargar}><RefreshCw size={15} className="mr-1" /> Actualizar</Button>
</div>
{!lic.aplicada ? (
<div className="mt-3 rounded-xl bg-theme-accent-bg px-4 py-3 text-sm text-theme-heading">
Sin licencia aplicada — todas las funciones habilitadas.
</div>
) : (
<div className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div><p className="text-xs text-theme-muted">Plan</p><p className="font-medium text-theme-heading">{lic.plan_label || lic.plan || '—'}</p></div>
<div><p className="text-xs text-theme-muted">Cliente</p><p className="font-medium text-theme-heading">{lic.cliente || '—'}</p></div>
<div><p className="text-xs text-theme-muted">Vence</p><p className="font-medium text-theme-heading">{lic.vence || 'sin vencimiento'}{lic.dias_restantes !== null && ` (${lic.dias_restantes} días)`}</p></div>
<div>
<p className="text-xs text-theme-muted">Modo</p>
<Select
value={lic.modo}
options={[{ value: 'aviso', label: 'Aviso' }, { value: 'bloqueo', label: 'Bloqueo' }]}
onChange={(e) => cambiarModo(e.target.value as 'aviso' | 'bloqueo')}
/>
</div>
</div>
)}
{lic.aviso_vence && (
<p className="mt-3 flex items-center gap-2 text-sm text-amber-600">
<AlertTriangle size={16} /> La licencia vence en {lic.dias_restantes} días.
</p>
)}
{lic.aplicada && lic.modo === 'aviso' && (
<p className="mt-2 text-xs text-theme-muted">
Modo aviso: los módulos fuera del plan y los límites solo se advierten; no se bloquea nada.
</p>
)}
</Card.Body>
</Card>
<Card>
<Card.Body>
<h4 className="font-heading text-lg text-theme-heading mb-2 flex items-center"><KeyRound size={17} className="mr-2" /> Clave de licencia</h4>
<TextArea
label="Pega aquí la clave que te entregó tu proveedor"
rows={3}
value={clave}
onChange={(e) => setClave(e.target.value)}
placeholder="SKN1...."
/>
{resultadoValidacion && (
<p className={`mt-2 text-sm ${resultadoValidacion.valida ? 'text-emerald-600' : 'text-rose-600'}`}>
{resultadoValidacion.valida ? 'Clave válida.' : `Clave rechazada: ${resultadoValidacion.error}`}
</p>
)}
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="outline" onClick={validar} disabled={validando}>{validando ? 'Validando…' : 'Validar'}</Button>
<Button onClick={aplicar} disabled={aplicando}><BadgeCheck size={15} className="mr-1" />{aplicando ? 'Aplicando…' : 'Aplicar licencia'}</Button>
{lic.aplicada && (
<Button variant="ghost" onClick={quitar}><Trash2 size={15} className="mr-1" /> Quitar licencia</Button>
)}
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h4 className="font-heading text-lg text-theme-heading mb-3">Uso vs límites</h4>
<div className="space-y-3">
{estado.limites.map((f) => (
<div key={f.clave}>
<div className="flex justify-between text-sm">
<span className="text-theme-muted">{f.label}</span>
<span className="text-theme-heading">
{f.uso}{f.limite > 0 ? ` / ${f.limite}` : ' (sin límite)'}
</span>
</div>
<div className="mt-1 h-2 rounded-full bg-theme-bg overflow-hidden">
<div className={`h-full ${ESTADO_COLOR[f.estado] || 'bg-theme-accent'}`}
style={{ width: f.limite > 0 ? `${Math.min(100, f.porcentaje)}%` : '0%' }} />
</div>
</div>
))}
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h4 className="font-heading text-lg text-theme-heading mb-3">Módulos del plan</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{estado.modulos.map((m) => (
<div key={m.clave} className={`flex items-center justify-between rounded-lg px-3 py-2 text-sm ${m.incluido ? 'bg-emerald-50 text-emerald-800' : 'bg-rose-50 text-rose-700'}`}>
<span>{m.label}</span>
<span className="text-xs">{m.base ? 'base' : (m.incluido ? 'incluido' : 'no incluido')}</span>
</div>
))}
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h4 className="font-heading text-lg text-theme-heading mb-3">Planes disponibles</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-2 text-xs text-theme-muted uppercase">Plan</th>
<th className="text-left p-2 text-xs text-theme-muted uppercase">Módulos</th>
</tr>
</thead>
<tbody className="divide-y">
{estado.planes.map((p) => (
<tr key={p.clave}>
<td className="p-2 font-medium text-theme-heading">{p.label}</td>
<td className="p-2 text-theme-muted">{p.modulos.length ? p.modulos.join(', ') : 'todos'}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card.Body>
</Card>
</div>
);
};
export default LicenciaPanel;

View File

@@ -51,7 +51,7 @@ import { useGlosario, traducirMenu } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
import { odooApi, licenciaModuloIncluido } from '../services/odoo';
interface LayoutProps {
children: ReactNode;
@@ -135,7 +135,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg, licencia: licenciaCfg } = useClinicSettings();
const menuGroupsT = useMemo(() => {
let base = menuGroups;
if (membresiasCfg.activo) {
@@ -153,8 +153,15 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
? { ...g, items: [...g.items, COMISIONES_ITEM] }
: g));
}
// Licencia en modo bloqueo sin WhatsApp: se ocultan el chat y la sección WACRM.
if (!licenciaModuloIncluido(licenciaCfg, 'whatsapp')) {
base = base
.filter((g) => g.label !== 'WACRM')
.map((g) => ({ ...g, items: g.items.filter((i) => i.href !== '/chat') }))
.filter((g) => g.items.length > 0);
}
return traducirMenu(base, term);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo, licenciaCfg]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas

View File

@@ -0,0 +1,260 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Plus, RefreshCw, Send, Trash2, Webhook as WebhookIcon, Pencil, RotateCw } from 'lucide-react';
import { Button, Card, Input, Modal, Select, TextArea, toast } from './ui';
import { odooApi, type Webhook, type WebhookEvento, type WebhookEventoCatalogo } from '../services/odoo';
// Pestaña Configuración -> Webhooks.
const ESTADO_BADGE: Record<string, string> = {
enviado: 'bg-emerald-100 text-emerald-700',
fallido: 'bg-rose-100 text-rose-700',
pendiente: 'bg-amber-100 text-amber-700',
descartado: 'bg-theme-bg text-theme-muted',
};
interface FormState {
name: string; url: string; eventos: string[]; activo: boolean; secreto: string; notas: string;
}
const FORM_VACIO: FormState = { name: '', url: '', eventos: [], activo: true, secreto: '', notas: '' };
const WebhooksPanel: FC = () => {
const [hooks, setHooks] = useState<Webhook[]>([]);
const [catalogo, setCatalogo] = useState<WebhookEventoCatalogo[]>([]);
const [, setCargando] = useState(true);
const [form, setForm] = useState<FormState>(FORM_VACIO);
const [editId, setEditId] = useState<number | null>(null);
const [mostrarForm, setMostrarForm] = useState(false);
const [eventoEjemplo, setEventoEjemplo] = useState<string>('cita.creada');
const [logDe, setLogDe] = useState<Webhook | null>(null);
const [log, setLog] = useState<WebhookEvento[]>([]);
const [filtroEstado, setFiltroEstado] = useState('');
const [guardando, setGuardando] = useState(false);
const cargar = useCallback(async () => {
try {
const res = await odooApi.listWebhooks();
if (res.status === 'success') { setHooks(res.webhooks); setCatalogo(res.eventos); }
} catch {
toast.error('No se pudieron cargar los webhooks');
} finally {
setCargando(false);
}
}, []);
useEffect(() => { cargar(); }, [cargar]);
const abrirNuevo = () => { setForm(FORM_VACIO); setEditId(null); setMostrarForm(true); };
const abrirEditar = (w: Webhook) => {
setForm({ name: w.name, url: w.url, eventos: w.eventos, activo: w.activo, secreto: '', notas: w.notas });
setEditId(w.id); setMostrarForm(true);
};
const guardar = async () => {
if (!form.name.trim() || !form.url.trim()) { toast.error('Nombre y URL son obligatorios'); return; }
setGuardando(true);
try {
if (editId) {
await odooApi.updateWebhook(editId, { name: form.name, url: form.url, eventos: form.eventos, activo: form.activo, notas: form.notas });
toast.success('Webhook actualizado');
} else {
const res = await odooApi.createWebhook({ name: form.name, url: form.url, eventos: form.eventos, activo: form.activo, secreto: form.secreto, notas: form.notas });
toast.success(`Webhook creado. Secreto: ${res.secreto}`);
}
setMostrarForm(false);
await cargar();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar el webhook');
} finally {
setGuardando(false);
}
};
const eliminar = async (w: Webhook) => {
if (!window.confirm(`¿Eliminar el webhook "${w.name}"?`)) return;
try { await odooApi.deleteWebhook(w.id); toast.success('Eliminado'); await cargar(); }
catch { toast.error('No se pudo eliminar'); }
};
const probar = async (w: Webhook) => {
try {
const res = await odooApi.probarWebhook(w.id);
if (res.ok) toast.success(res.message);
else toast.error(res.message);
await cargar();
} catch { toast.error('No se pudo probar el webhook'); }
};
const verLog = async (w: Webhook, estado = filtroEstado) => {
try {
const res = await odooApi.listWebhookEventos(w.id, { estado: estado || undefined, limit: 100 });
setLog(res.eventos); setLogDe(w);
} catch { toast.error('No se pudo cargar el log'); }
};
const reintentar = async (e: WebhookEvento) => {
try {
const res = await odooApi.reintentarWebhookEvento(e.id);
toast.success(`Reintento: ${res.estado}`);
if (logDe) await verLog(logDe);
await cargar();
} catch { toast.error('No se pudo reintentar'); }
};
const toggleEvento = (c: string) => setForm((f) => ({ ...f, eventos: f.eventos.includes(c) ? f.eventos.filter((x) => x !== c) : [...f.eventos, c] }));
const ejemplo = catalogo.find((c) => c.clave === eventoEjemplo)?.ejemplo || {};
return (
<div className="space-y-5">
<Card>
<Card.Body>
<div className="flex items-center justify-between gap-3">
<h3 className="font-heading text-xl text-theme-heading flex items-center"><WebhookIcon size={19} className="mr-2" /> Webhooks salientes</h3>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={cargar}><RefreshCw size={15} className="mr-1" /> Actualizar</Button>
<Button size="sm" onClick={abrirNuevo}><Plus size={15} className="mr-1" /> Nuevo webhook</Button>
</div>
</div>
<p className="mt-2 text-xs text-theme-muted">
Cada entrega lleva <code>X-Skeen-Event</code>, <code>X-Skeen-Delivery</code> y
<code> X-Skeen-Signature: sha256=&lt;HMAC-SHA256 del cuerpo con el secreto&gt;</code>.
La entrega es asíncrona (cron cada 5 min) con reintentos y backoff.
</p>
</Card.Body>
</Card>
<Card>
<Card.Body>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-2 text-xs text-theme-muted uppercase">Nombre</th>
<th className="text-left p-2 text-xs text-theme-muted uppercase">URL</th>
<th className="text-left p-2 text-xs text-theme-muted uppercase">Eventos</th>
<th className="text-left p-2 text-xs text-theme-muted uppercase">Última entrega</th>
<th className="text-left p-2 text-xs text-theme-muted uppercase">Estado</th>
<th className="text-right p-2 text-xs text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{hooks.map((w) => (
<tr key={w.id}>
<td className="p-2 font-medium text-theme-heading">{w.name}</td>
<td className="p-2 text-theme-muted max-w-[240px] truncate">{w.url}</td>
<td className="p-2 text-theme-muted max-w-[220px] truncate">{w.eventos_label}</td>
<td className="p-2 text-theme-muted">{w.ultima_entrega_at || '—'}</td>
<td className="p-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${w.activo ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'}`}>
{w.activo ? 'activo' : 'inactivo'}{w.fallos_consecutivos ? ` (${w.fallos_consecutivos} fallos)` : ''}
</span>
</td>
<td className="p-2 text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" title="Probar" onClick={() => probar(w)}><Send size={14} /></Button>
<Button variant="ghost" size="sm" title="Log de entregas" onClick={() => verLog(w, '')}><RefreshCw size={14} /></Button>
<Button variant="ghost" size="sm" title="Editar" onClick={() => abrirEditar(w)}><Pencil size={14} /></Button>
<Button variant="ghost" size="sm" title="Eliminar" onClick={() => eliminar(w)}><Trash2 size={14} /></Button>
</div>
</td>
</tr>
))}
{hooks.length === 0 && (
<tr><td colSpan={6} className="p-4 text-center text-theme-muted">No hay webhooks configurados. Sin webhooks activos no se emite ningún evento.</td></tr>
)}
</tbody>
</table>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h4 className="font-heading text-lg text-theme-heading mb-2">Catálogo de eventos</h4>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-1">
{catalogo.map((c) => (
<button key={c.clave} type="button" onClick={() => setEventoEjemplo(c.clave)}
className={`block w-full text-left rounded-lg px-3 py-1.5 text-sm ${eventoEjemplo === c.clave ? 'bg-theme-accent-bg text-theme-heading' : 'text-theme-muted hover:bg-theme-bg'}`}>
<code>{c.clave}</code> — {c.label}
</button>
))}
</div>
<div>
<p className="text-xs text-theme-muted mb-1">Payload de ejemplo</p>
<pre className="rounded-xl bg-theme-bg p-3 text-xs overflow-auto max-h-72">{JSON.stringify(ejemplo, null, 2)}</pre>
</div>
</div>
</Card.Body>
</Card>
<Modal isOpen={mostrarForm} onClose={() => setMostrarForm(false)} title={editId ? 'Editar webhook' : 'Nuevo webhook'} maxWidth="2xl"
footer={<><Button variant="outline" onClick={() => setMostrarForm(false)}>Cancelar</Button><Button onClick={guardar} disabled={guardando}>{guardando ? 'Guardando…' : 'Guardar'}</Button></>}>
<div className="space-y-3">
<Input label="Nombre" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Input label="URL" value={form.url} onChange={(e) => setForm({ ...form, url: e.target.value })} placeholder="https://cliente.example/hook" />
<label className="flex items-center gap-2 text-sm text-theme-muted">
<input type="checkbox" checked={form.activo} onChange={(e) => setForm({ ...form, activo: e.target.checked })} className="h-4 w-4 rounded" /> Activo
</label>
<div>
<p className="text-xs font-medium text-theme-muted mb-1.5">Eventos (vacío = todos)</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 max-h-52 overflow-auto">
{catalogo.map((c) => (
<label key={c.clave} className="flex items-center gap-2 text-sm text-theme-muted">
<input type="checkbox" checked={form.eventos.includes(c.clave)} onChange={() => toggleEvento(c.clave)} className="h-4 w-4 rounded" />
{c.label}
</label>
))}
</div>
</div>
{!editId && (
<Input label="Secreto (vacío = se genera uno)" value={form.secreto} onChange={(e) => setForm({ ...form, secreto: e.target.value })} />
)}
<TextArea label="Notas" rows={2} value={form.notas} onChange={(e) => setForm({ ...form, notas: e.target.value })} />
</div>
</Modal>
<Modal isOpen={!!logDe} onClose={() => setLogDe(null)} title={`Entregas — ${logDe?.name || ''}`} maxWidth="2xl">
<div className="flex items-center gap-2 mb-3">
<Select value={filtroEstado} options={[{ value: '', label: 'Todos' }, { value: 'pendiente', label: 'Pendiente' }, { value: 'enviado', label: 'Enviado' }, { value: 'fallido', label: 'Fallido' }, { value: 'descartado', label: 'Descartado' }]}
onChange={(e) => { setFiltroEstado(e.target.value); if (logDe) verLog(logDe, e.target.value); }} />
<Button variant="ghost" size="sm" onClick={() => logDe && verLog(logDe)}><RefreshCw size={14} /></Button>
</div>
<div className="overflow-auto max-h-96">
<table className="w-full text-xs">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-2">Evento</th>
<th className="text-left p-2">Estado</th>
<th className="text-left p-2">Código</th>
<th className="text-left p-2">Fecha</th>
<th className="text-right p-2">Acción</th>
</tr>
</thead>
<tbody className="divide-y">
{log.map((e) => (
<tr key={e.id}>
<td className="p-2">{e.evento_label}<div className="text-theme-muted">{e.respuesta_texto?.slice(0, 60)}</div></td>
<td className="p-2"><span className={`px-2 py-0.5 rounded-full ${ESTADO_BADGE[e.estado] || 'bg-theme-bg'}`}>{e.estado_label}</span>{e.intentos > 1 && <span className="text-theme-muted"> ×{e.intentos}</span>}</td>
<td className="p-2">{e.respuesta_codigo || '—'}</td>
<td className="p-2">{e.enviado_at || e.creado_at}</td>
<td className="p-2 text-right">
{(e.estado === 'fallido' || e.estado === 'pendiente') && (
<Button variant="ghost" size="sm" onClick={() => reintentar(e)}><RotateCw size={13} /></Button>
)}
</td>
</tr>
))}
{log.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-theme-muted">Sin entregas.</td></tr>}
</tbody>
</table>
</div>
</Modal>
</div>
);
};
export default WebhooksPanel;

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, COMPRAS_DEFAULT, COMISIONES_DEFAULT } from '../services/odoo';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_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.
@@ -14,6 +14,11 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
licencia: {
...LICENCIA_DEFAULT,
...(settings?.licencia || {}),
limites: { ...LICENCIA_LIMITES_DEFAULT, ...((settings?.licencia || {}).limites || {}) },
},
});
let actual: ClinicSettings = normalizar(null);

View File

@@ -24,6 +24,7 @@ export const PERM_KEYS: string[] = [
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
'api.claves', 'webhooks.editar',
];
const W = ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia',
@@ -84,6 +85,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'proveedores.ver': ALL, 'proveedores.editar': AG,
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
'api.claves': AD, 'webhooks.editar': AG,
};
export const BASE_ROLES = ['admin', 'medico', 'recepcion', 'enfermeria',

View File

@@ -21,6 +21,9 @@ import FormulariosPanel from '../components/FormulariosPanel';
import MembresiasPanel from '../components/MembresiasPanel';
import ComprasPanel from '../components/ComprasPanel';
import ComisionesPanel from '../components/ComisionesPanel';
import LicenciaPanel from '../components/LicenciaPanel';
import ApiKeysPanel from '../components/ApiKeysPanel';
import WebhooksPanel from '../components/WebhooksPanel';
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';
@@ -44,11 +47,14 @@ const TABS = [
{ key: 'recetas', label: 'Recetas' },
{ key: 'catalogos', label: 'Catálogos' },
{ key: 'tickets', label: 'Tickets' },
{ key: 'licencia', label: 'Licencia' },
{ key: 'api', label: 'API' },
{ key: 'webhooks', label: 'Webhooks' },
{ key: 'auditoria', label: 'Auditoría' },
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets'];
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -76,6 +82,9 @@ const Configuracion: FC = () => {
compras: 'compras.editar',
comisiones: 'comisiones.editar',
recursos: 'recursos.editar',
licencia: 'config.editar',
api: 'api.claves',
webhooks: 'webhooks.editar',
};
const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
@@ -445,6 +454,12 @@ const Configuracion: FC = () => {
<FormulariosPanel />
</Card.Body>
</Card>
) : tab === 'licencia' ? (
<LicenciaPanel />
) : tab === 'api' ? (
<ApiKeysPanel />
) : tab === 'webhooks' ? (
<WebhooksPanel />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card>

View File

@@ -1076,6 +1076,138 @@ export interface ClinicSettings {
recursos: RecursosSettings;
compras: ComprasSettings;
comisiones: ComisionesSettings;
licencia: LicenciaSettings;
}
// ---- Licencia por plan (bloque `licencia` de clinic-settings) ----
export interface LicenciaLimites {
usuarios_max: number;
sucursales_max: number;
citas_mes_max: number;
almacenamiento_mb: number;
}
export interface LicenciaSettings {
plan: '' | 'basico' | 'profesional' | 'enterprise' | 'custom';
modo: 'aviso' | 'bloqueo';
cliente: string;
vence: string;
clave: string;
modulos: string[];
limites: LicenciaLimites;
}
export const LICENCIA_LIMITES_DEFAULT: LicenciaLimites = {
usuarios_max: 0, sucursales_max: 0, citas_mes_max: 0, almacenamiento_mb: 0,
};
export const LICENCIA_DEFAULT: LicenciaSettings = {
plan: '', modo: 'aviso', cliente: '', vence: '', clave: '', modulos: [],
limites: { ...LICENCIA_LIMITES_DEFAULT },
};
/** True si la licencia está aplicada y en modo bloqueo. */
export const licenciaBloqueo = (s?: Partial<LicenciaSettings> | null): boolean =>
Boolean(s && s.clave && s.modo === 'bloqueo');
/** True si el módulo gateable está incluido (sin licencia/bloqueo = sí). */
export const licenciaModuloIncluido = (s: Partial<LicenciaSettings> | null | undefined, modulo: string): boolean => {
if (!licenciaBloqueo(s)) return true;
const modulos = s?.modulos || [];
if (modulos.length === 0) return true;
return modulos.includes(modulo);
};
export interface LicenciaEstado {
plan: string;
plan_label: string;
modo: 'aviso' | 'bloqueo';
cliente: string;
vence: string;
dias_restantes: number | null;
aplicada: boolean;
bloqueo_activo: boolean;
aviso_vence: boolean;
}
export interface LicenciaLimiteFila {
clave: string;
label: string;
uso: number;
limite: number;
porcentaje: number;
estado: 'ok' | 'cerca' | 'excedido' | 'sin_limite';
}
export interface LicenciaModuloFila {
clave: string;
label: string;
base: boolean;
incluido: boolean;
}
export interface LicenciaEstadoResponse {
status: string;
licencia: LicenciaEstado;
limites: LicenciaLimiteFila[];
modulos: LicenciaModuloFila[];
planes: { clave: string; label: string; modulos: string[] }[];
aviso: string | null;
message?: string;
}
export interface ApiKey {
id: number;
name: string;
prefijo: string;
activo: boolean;
scopes: string[];
scopes_label: string;
vence: string;
ultimo_uso: string;
usos: number;
notas: string;
creado_por_login: string;
revocada_at: string;
revocada_por: string;
estado: 'activa' | 'vencida' | 'revocada';
}
export interface Webhook {
id: number;
name: string;
url: string;
eventos: string[];
eventos_label: string;
activo: boolean;
tiene_secreto: boolean;
headers: Record<string, string>;
notas: string;
creado_por_login: string;
ultima_entrega_at: string;
fallos_consecutivos: number;
}
export interface WebhookEvento {
id: number;
webhook_id: number;
webhook: string;
evento: string;
evento_label: string;
estado: 'pendiente' | 'enviado' | 'fallido' | 'descartado';
estado_label: string;
intentos: number;
proximo_intento: string;
respuesta_codigo: number;
respuesta_texto: string;
enviado_at: string;
creado_at: string;
}
export interface WebhookEventoCatalogo {
clave: string;
label: string;
ejemplo: Record<string, unknown>;
}
// ---- Reservas en línea (portal público de auto-agendado) ----
@@ -2082,6 +2214,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
recursos: RECURSOS_DEFAULT,
compras: COMPRAS_DEFAULT,
comisiones: COMISIONES_DEFAULT,
licencia: LICENCIA_DEFAULT,
};
export interface FrontendUser {
@@ -2776,6 +2909,79 @@ export const odooApi = {
return data;
},
// ---- Licencia por plan ----
async getLicencia(): Promise<LicenciaEstadoResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/licencia`);
return data;
},
async putLicencia(payload: { clave?: string; modo?: 'aviso' | 'bloqueo' }): Promise<LicenciaEstadoResponse> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/licencia`, payload);
return data;
},
async validarLicencia(clave: string): Promise<{ status: string; valida: boolean; error: string | null; payload: Record<string, unknown> | null; dias_restantes: number | null; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/licencia/validar`, { clave });
return data;
},
// ---- Claves de API de terceros ----
async listApiKeys(): Promise<{ status: string; api_keys: ApiKey[]; scopes: { clave: string; label: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/api_keys`);
return data;
},
async createApiKey(payload: { name: string; scopes?: string[]; vence?: string; notas?: string }): Promise<{ status: string; clave: string; api_key: ApiKey; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/api_keys`, payload);
return data;
},
async updateApiKey(id: number, payload: { name?: string; scopes?: string[]; activo?: boolean; vence?: string; notas?: string }): Promise<{ status: string; api_key: ApiKey }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/api_keys/${id}`, payload);
return data;
},
async revocarApiKey(id: number): Promise<{ status: string; api_key: ApiKey; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/api_keys/${id}/revocar`);
return data;
},
// ---- Webhooks salientes ----
async listWebhooks(): Promise<{ status: string; webhooks: Webhook[]; eventos: WebhookEventoCatalogo[]; estados: { clave: string; label: string }[]; formato_firma: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/webhooks`);
return data;
},
async createWebhook(payload: { name: string; url: string; eventos: string[]; activo?: boolean; secreto?: string; headers?: Record<string, string>; notas?: string }): Promise<{ status: string; webhook: Webhook; secreto: string; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/webhooks`, payload);
return data;
},
async updateWebhook(id: number, payload: Partial<{ name: string; url: string; eventos: string[]; activo: boolean; secreto: string; notas: string }>): Promise<{ status: string; webhook: Webhook }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}`, payload);
return data;
},
async deleteWebhook(id: number): Promise<{ status: string; message?: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}`);
return data;
},
async listWebhookEventos(id: number, params?: { estado?: string; evento?: string; limit?: number }): Promise<{ status: string; eventos: WebhookEvento[]; total: number; resumen: Record<string, number> }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}/eventos`, { params });
return data;
},
async probarWebhook(id: number): Promise<{ status: string; ok: boolean; message: string; evento: WebhookEvento | null }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/webhooks/${id}/probar`);
return data;
},
async reintentarWebhookEvento(id: number): Promise<{ status: string; estado: string; evento: WebhookEvento }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/webhook_eventos/${id}/reintentar`);
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).