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:
230
frontend/src/components/ApiKeysPanel.tsx
Normal file
230
frontend/src/components/ApiKeysPanel.tsx
Normal 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 <clave></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;
|
||||
@@ -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();
|
||||
|
||||
232
frontend/src/components/LicenciaPanel.tsx
Normal file
232
frontend/src/components/LicenciaPanel.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
260
frontend/src/components/WebhooksPanel.tsx
Normal file
260
frontend/src/components/WebhooksPanel.tsx
Normal 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=<HMAC-SHA256 del cuerpo con el secreto></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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user