bloque 1: anticipo al reservar en linea y cobro automatico de membresias con enlace de pago (modulo skeen_cobros: enlaces con token firmado, webhooks de Stripe/MercadoPago, modo simulado para pruebas, pagina publica de pago, expiracion automatica y reembolso, reintentos de cobro, permisos y reporte); apagado por defecto

This commit is contained in:
2026-10-11 23:54:47 +00:00
parent 5fc0d2a780
commit 6a6c2a928b
35 changed files with 2841 additions and 32 deletions

View File

@@ -42,6 +42,7 @@ import MisVentas from './pages/MisVentas';
import Login from './pages/Login';
import Reservar from './pages/Reservar';
import ReservarCita from './pages/ReservarCita';
import Pagar from './pages/Pagar';
import FormularioPublico from './pages/FormularioPublico';
import './App.css';
@@ -180,6 +181,8 @@ const App: FC = () => {
{/* Portal público de reservas: FUERA del gate de autenticación. */}
<Route path="/reservar" element={<Reservar />} />
<Route path="/reservar/cita/:token" element={<ReservarCita />} />
{/* Página pública de pago de un enlace de cobro (token HMAC). */}
<Route path="/pagar/:token" element={<Pagar />} />
{/* Portal público de formularios: token HMAC como credencial. */}
<Route path="/formulario/:token" element={<FormularioPublico />} />
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />

View File

@@ -3,10 +3,12 @@ import { useCallback, useEffect, useState } from 'react';
import { Repeat, Plus, Pencil, Trash2, Save } from 'lucide-react';
import { Button, Input, TextArea, Select, Modal, toast } from './ui';
import {
odooApi, MEMBRESIAS_DEFAULT,
type ClinicSettings, type MembresiasSettings, type MembresiaPlan,
odooApi, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT,
type ClinicSettings, type MembresiasSettings, type CobroAutomaticoSettings,
type MembresiaPlan,
type Service, type ServiceCategory, type PeriodicidadMembresia,
} from '../services/odoo';
import { useI18n } from '../lib/i18n';
// Configuración → Membresías: ajustes del bloque + CRUD de planes.
// Mientras `activo` esté apagado, la sección no aparece en el menú y el POS no
@@ -71,6 +73,7 @@ const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => vo
);
const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
const { t } = useI18n();
const [form, setForm] = useState<MembresiasSettings>(settings.membresias || MEMBRESIAS_DEFAULT);
const [saving, setSaving] = useState(false);
const [avisos, setAvisos] = useState((settings.membresias?.dias_aviso_renovacion || [7, 1]).join(', '));
@@ -115,13 +118,27 @@ const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
const set = <K extends keyof MembresiasSettings>(k: K, v: MembresiasSettings[K]) =>
setForm((prev) => ({ ...prev, [k]: v }));
const cobro: CobroAutomaticoSettings = form.cobro_automatico || COBRO_AUTOMATICO_DEFAULT;
const setCobro = <K extends keyof CobroAutomaticoSettings>(k: K, v: CobroAutomaticoSettings[K]) =>
setForm((prev) => ({ ...prev, cobro_automatico: { ...(prev.cobro_automatico || COBRO_AUTOMATICO_DEFAULT), [k]: v } }));
const [reintentosTxt, setReintentosTxt] = useState(
(settings.membresias?.cobro_automatico?.reintentos_dias || [1, 3, 7]).join(', '));
useEffect(() => {
setReintentosTxt((settings.membresias?.cobro_automatico?.reintentos_dias || [1, 3, 7]).join(', '));
}, [settings.membresias]);
const guardarConfig = async () => {
const dias = avisos.split(',').map((d) => parseInt(d.trim(), 10)).filter((d) => !Number.isNaN(d));
if (dias.length === 0) { toast.error('Indica al menos un día de aviso (ej. 7, 1)'); return; }
const reintentos = reintentosTxt.split(',').map((d) => parseInt(d.trim(), 10)).filter((d) => !Number.isNaN(d));
setSaving(true);
try {
const res = await odooApi.putClinicSettings({
membresias: { ...form, dias_aviso_renovacion: dias },
membresias: {
...form,
dias_aviso_renovacion: dias,
cobro_automatico: { ...cobro, reintentos_dias: reintentos },
},
motivo: 'Configuración de membresías',
});
if (res.status === 'success') {
@@ -270,6 +287,48 @@ const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
<Toggle label="Permitir pausa por falta de pago" checked={form.permitir_pausa_pago}
onChange={(v) => set('permitir_pausa_pago', v)} />
</div>
{/* Cobro automático del periodo con enlace de pago. Apagado por defecto. */}
<div className="rounded-2xl border border-theme-border p-4">
<h4 className="font-heading text-base text-theme-heading">{t('config.cobroAuto.title')}</h4>
<p className="mt-1 text-sm text-theme-muted">{t('config.cobroAuto.help')}</p>
<div className="mt-3">
<Toggle label={t('config.cobroAuto.enable')} checked={cobro.activo}
onChange={(v) => setCobro('activo', v)}
ayuda={t('config.cobroAuto.enableHelp')} />
</div>
{cobro.activo && (
<div className="mt-4 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Select label={t('config.cobroAuto.method')} value={cobro.metodo}
onChange={(e) => setCobro('metodo', e.target.value as CobroAutomaticoSettings['metodo'])}
options={[
{ value: 'enlace', label: t('config.cobroAuto.methodLink') },
{ value: 'tarjeta_guardada', label: t('config.cobroAuto.methodCard') },
]} />
<Input label={t('config.cobroAuto.daysBefore')} type="number" min={0} max={365}
value={String(cobro.dias_antes)}
onChange={(e) => setCobro('dias_antes', parseInt(e.target.value, 10) || 0)} />
<Input label={t('config.cobroAuto.grace')} type="number" min={0} max={365}
value={String(cobro.dias_gracia)}
onChange={(e) => setCobro('dias_gracia', parseInt(e.target.value, 10) || 0)} />
</div>
<div>
<label className="block text-xs font-medium text-theme-muted mb-1.5">{t('config.cobroAuto.retries')}</label>
<Input value={reintentosTxt} onChange={(e) => setReintentosTxt(e.target.value)} placeholder="1, 3, 7" />
<p className="text-[11px] text-theme-muted mt-1">{t('config.cobroAuto.retriesHelp')}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Toggle label={t('config.cobroAuto.cancel')} checked={cobro.cancelar_si_no_paga}
onChange={(v) => setCobro('cancelar_si_no_paga', v)}
ayuda={t('config.cobroAuto.cancelHelp')} />
<Toggle label={t('config.cobroAuto.simulated')} checked={cobro.permitir_simulado}
onChange={(v) => setCobro('permitir_simulado', v)}
ayuda={t('config.cobroAuto.simulatedHelp')} />
</div>
</div>
)}
</div>
<div className="flex justify-end">
<Button onClick={guardarConfig} disabled={saving}>
<Save size={16} className="mr-2" /> {saving ? 'Guardando…' : 'Guardar configuración'}

View File

@@ -1,12 +1,13 @@
import type { FC, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { CalendarClock, Copy, ExternalLink, Save } from 'lucide-react';
import { Button, Input, TextArea, toast } from './ui';
import { Button, Input, Select, TextArea, toast } from './ui';
import {
odooApi, RESERVAS_ONLINE_DEFAULT,
type ClinicSettings, type ReservasOnlineSettings,
odooApi, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT,
type ClinicSettings, type ReservasOnlineSettings, type AnticipoSettings,
} from '../services/odoo';
import { enlaceReserva } from '../lib/publicBooking';
import { useI18n } from '../lib/i18n';
// Editor del bloque `reservas_online` (portal público de auto-agendado) y
// botón para copiar/abrir el enlace público de la clínica.
@@ -17,6 +18,7 @@ interface Props {
}
const ReservasOnlinePanel: FC<Props> = ({ settings, onSaved }) => {
const { t } = useI18n();
const [form, setForm] = useState<ReservasOnlineSettings>(
settings.reservas_online || RESERVAS_ONLINE_DEFAULT);
const [saving, setSaving] = useState(false);
@@ -29,6 +31,10 @@ const ReservasOnlinePanel: FC<Props> = ({ settings, onSaved }) => {
const set = <K extends keyof ReservasOnlineSettings>(k: K, v: ReservasOnlineSettings[K]) =>
setForm((prev) => ({ ...prev, [k]: v }));
const anticipo: AnticipoSettings = form.anticipo || ANTICIPO_DEFAULT;
const setAnticipo = <K extends keyof AnticipoSettings>(k: K, v: AnticipoSettings[K]) =>
setForm((prev) => ({ ...prev, anticipo: { ...(prev.anticipo || ANTICIPO_DEFAULT), [k]: v } }));
const enlace = enlaceReserva(settings.url_publica);
const guardar = async () => {
@@ -119,6 +125,51 @@ const ReservasOnlinePanel: FC<Props> = ({ settings, onSaved }) => {
onChange={(e) => set('mensaje_confirmacion', e.target.value)} rows={2}
placeholder="Se muestra al cliente al terminar su reserva" />
{/* Anticipo (depósito) al reservar en línea. Apagado por defecto. */}
<div className="rounded-2xl border border-theme-border p-4">
<h4 className="font-heading text-base text-theme-heading">{t('config.anticipo.title')}</h4>
<p className="mt-1 text-sm text-theme-muted">{t('config.anticipo.help')}</p>
<div className="mt-3">
<Toggle label={t('config.anticipo.enable')} checked={anticipo.activo}
onChange={(v) => setAnticipo('activo', v)}
ayuda={t('config.anticipo.enableHelp')} />
</div>
{anticipo.activo && (
<div className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Select label={t('config.anticipo.type')} value={anticipo.tipo}
onChange={(e) => setAnticipo('tipo', e.target.value as AnticipoSettings['tipo'])}
options={[
{ value: 'porcentaje', label: t('config.anticipo.typePercent') },
{ value: 'monto_fijo', label: t('config.anticipo.typeFixed') },
]} />
<Input label={t('config.anticipo.value')} type="number" min={0}
value={String(anticipo.valor)}
onChange={(e) => setAnticipo('valor', Number(e.target.value) || 0)} />
<Select label={t('config.anticipo.currency')} value={anticipo.moneda}
onChange={(e) => setAnticipo('moneda', e.target.value)}
options={[{ value: 'MXN', label: 'MXN' }, { value: 'USD', label: 'USD' }]} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input label={t('config.anticipo.hours')} type="number" min={1} max={720}
value={String(anticipo.horas_para_pagar)}
onChange={(e) => setAnticipo('horas_para_pagar', Number(e.target.value) || 1)} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Toggle label={t('config.anticipo.refundable')} checked={anticipo.reembolsable}
onChange={(v) => setAnticipo('reembolsable', v)}
ayuda={t('config.anticipo.refundableHelp')} />
<Toggle label={t('config.anticipo.simulated')} checked={anticipo.permitir_simulado}
onChange={(v) => setAnticipo('permitir_simulado', v)}
ayuda={t('config.anticipo.simulatedHelp')} />
</div>
<TextArea label={t('config.anticipo.notice')} value={anticipo.texto_aviso}
onChange={(e) => setAnticipo('texto_aviso', e.target.value)} rows={2}
placeholder={t('config.anticipo.noticePlaceholder')} />
</div>
)}
</div>
<div className="rounded-2xl border border-theme-border bg-theme-bg p-4">
<p className="text-xs font-medium text-theme-muted">Enlace público de la clínica</p>
<div className="mt-2 flex flex-wrap items-center gap-2">

View File

@@ -0,0 +1,183 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { CreditCard, RefreshCw } from 'lucide-react';
import { Card, Button, EmptyState, Skeleton, Badge, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type Cobro } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
import { useAuth } from '../../lib/auth';
const fmtMoney = (value: number, currency = 'MXN') =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value || 0);
// Pestaña "Cobros" del módulo de Reportes: enlaces de cobro pendientes y
// expirados (anticipos de reservas en línea y periodos de membresía). Permite
// forzar el cobro y cancelar la retención según los permisos del rol.
const CobrosReport: FC = () => {
const { start, end } = useReportRange('cobros');
const { can } = useAuth();
const [cobros, setCobros] = useState<Cobro[]>([]);
const [resumen, setResumen] = useState({ pendiente: 0, expirado: 0, cancelado: 0 });
const [loading, setLoading] = useState(true);
const [accionando, setAccionando] = useState<number | null>(null);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getCobrosReport({ start, end });
if (res.status === 'success') {
setCobros(res.cobros);
setResumen(res.resumen);
} else {
toast.error(res.message || 'No se pudo cargar el reporte');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cargar el reporte de cobros');
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const forzar = async (c: Cobro) => {
setAccionando(c.id);
try {
const res = await odooApi.forzarCobro(c.id, { referencia: 'Panel cobros' });
if (res.status === 'success') { toast.success('Cobro forzado'); await load(); }
else toast.error(res.message || 'No se pudo forzar el cobro');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo forzar el cobro');
} finally { setAccionando(null); }
};
const cancelar = async (c: Cobro) => {
setAccionando(c.id);
try {
const res = await odooApi.cancelarCobro(c.id, 'Cancelado desde Reportes');
if (res.status === 'success') { toast.success('Enlace cancelado'); await load(); }
else toast.error(res.message || 'No se pudo cancelar');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo cancelar');
} finally { setAccionando(null); }
};
const exportar = async () => {
await exportToExcel({
filename: `cobros-${start}-${end}`,
sheetName: 'Cobros',
title: 'Cobros pendientes y expirados',
subtitle: `Periodo ${start} a ${end}`,
columns: [
{ header: 'Folio', key: 'nombre' },
{ header: 'Origen', key: 'origen_label' },
{ header: 'Paciente', key: 'patient' },
{ header: 'Concepto', key: 'concepto' },
{ header: 'Monto', key: 'monto', format: 'currency' },
{ header: 'Estado', key: 'estado_label' },
{ header: 'Vencido', key: 'vencido' },
{ header: 'Creado', key: 'creado' },
{ header: 'Vence', key: 'expira' },
],
rows: cobros as unknown as Record<string, unknown>[],
});
};
if (loading) {
return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-theme-muted">Periodo {start} a {end}</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={load}>
<RefreshCw size={14} className="mr-1.5" /> Actualizar
</Button>
<ExportButton onClick={exportar} loading={false} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Card><Card.Body>
<p className="text-xs text-theme-muted">Pendientes</p>
<p className="font-heading text-2xl text-theme-heading">{fmtMoney(resumen.pendiente)}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Expirados</p>
<p className="font-heading text-2xl text-theme-heading">{fmtMoney(resumen.expirado)}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Cancelados</p>
<p className="font-heading text-2xl text-theme-heading">{fmtMoney(resumen.cancelado)}</p>
</Card.Body></Card>
</div>
{cobros.length === 0 ? (
<Card><Card.Body>
<EmptyState title="Sin cobros pendientes" subtitle="No hay enlaces pendientes ni expirados en el periodo."
icon={<CreditCard size={28} />} />
</Card.Body></Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-theme-border text-left text-xs uppercase tracking-wide text-theme-muted">
<th className="px-3 py-2">Folio</th>
<th className="px-3 py-2">Origen</th>
<th className="px-3 py-2">Paciente</th>
<th className="px-3 py-2">Concepto</th>
<th className="px-3 py-2 text-right">Monto</th>
<th className="px-3 py-2">Estado</th>
<th className="px-3 py-2">Vence</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{cobros.map((c) => (
<tr key={c.id} className="border-b border-theme-border/60">
<td className="px-3 py-2 text-theme-heading">{c.nombre}</td>
<td className="px-3 py-2 text-theme-muted">{c.origen_label}</td>
<td className="px-3 py-2 text-theme-muted">{c.patient || '—'}</td>
<td className="px-3 py-2 text-theme-heading">{c.concepto}</td>
<td className="px-3 py-2 text-right text-theme-heading">{fmtMoney(c.monto, c.moneda)}</td>
<td className="px-3 py-2">
<Badge variant={c.estado === 'expirado' ? 'danger' : c.estado === 'cancelado' ? 'cancelled' : 'warning'}>
{c.estado_label}{c.vencido ? ' · vencido' : ''}
</Badge>
</td>
<td className="px-3 py-2 text-theme-muted">{c.expira || '—'}</td>
<td className="px-3 py-2">
{c.estado === 'pendiente' && (
<div className="flex justify-end gap-1">
{can('cobros.forzar') && (
<Button size="sm" variant="outline" loading={accionando === c.id} onClick={() => forzar(c)}>
Forzar
</Button>
)}
{can('cobros.cancelar') && (
<Button size="sm" variant="ghost" onClick={() => cancelar(c)}>
Cancelar
</Button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
};
export default CobrosReport;

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, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT } from '../services/odoo';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_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.
@@ -9,8 +9,17 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
...CLINIC_SETTINGS_DEFAULT,
...(settings || {}),
impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) },
reservas_online: {
...RESERVAS_ONLINE_DEFAULT,
...(settings?.reservas_online || {}),
anticipo: { ...ANTICIPO_DEFAULT, ...((settings?.reservas_online || {}).anticipo || {}) },
},
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
membresias: {
...MEMBRESIAS_DEFAULT,
...(settings?.membresias || {}),
cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT, ...((settings?.membresias || {}).cobro_automatico || {}) },
},
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
@@ -31,6 +40,12 @@ const publicar = (s: ClinicSettings) => {
};
export const cargarClinicSettings = (): Promise<ClinicSettings> => {
// Sin sesión no hay nada que pedir: los ajustes requieren token y el portal
// público (reservas, pago, formularios) no lo tiene. Se devuelven los
// defaults para no ensuciar la consola con un 401.
if (typeof window !== 'undefined' && !window.localStorage.getItem('skeen_token')) {
return Promise.resolve(actual);
}
if (peticion) return peticion;
peticion = odooApi.getClinicSettings()
.then((res) => {

View File

@@ -388,6 +388,8 @@ export const es: Dict = {
'reports.tab.cortes.desc': 'Arqueos y cierres por día',
'reports.tab.adeudos': 'Adeudos',
'reports.tab.adeudos.desc': 'Cartera por cobrar y planes de pago',
'reports.tab.cobros': 'Cobros',
'reports.tab.cobros.desc': 'Anticipos y membresías por cobrar (pendientes y expirados)',
'reports.tab.devoluciones': 'Devoluciones',
'reports.tab.devoluciones.desc': 'Ventas devueltas y montos',
'reports.tab.top-clientes': 'Top clientes',
@@ -487,6 +489,35 @@ export const es: Dict = {
'public.manage.leadTime': ' (con menos de {h} h de anticipación)',
'public.manage.contactPhone': ' Comunícate al {phone}.',
'public.manage.contactClinic': ' Comunícate con la clínica.',
// Anticipo al reservar en línea
'public.awaitingDeposit': 'Reserva retenida: falta tu anticipo',
'public.depositAmount': 'Anticipo a pagar: {amount}',
'public.depositHelp': 'Tienes {h} horas para pagar el anticipo; si no se paga, la reserva se libera.',
'public.payDeposit': 'Pagar anticipo',
'public.depositExpires': 'El enlace vence en {h} horas',
// --- Página pública de pago de un enlace de cobro ---
'pay.title': 'Pago en línea',
'pay.folioWord': 'Folio',
'pay.concept': 'Concepto',
'pay.amount': 'Monto',
'pay.forPatient': 'A nombre de {name}',
'pay.payNow': 'Pagar ahora',
'pay.paySimulated': 'Confirmar pago (modo prueba)',
'pay.paidOk': '¡Pago registrado!',
'pay.receipt': 'Comprobante',
'pay.date': 'Fecha',
'pay.notAvailable': 'El pago en línea no está disponible en este momento. Comunícate con la clínica.',
'pay.expiresAt': 'Este enlace vence el {date}',
'pay.expiredBody': 'Este enlace ya expiró. Pide uno nuevo a la clínica.',
'pay.cancelledBody': 'Este enlace fue cancelado.',
'pay.unavailableTitle': 'Enlace no disponible',
'pay.invalidLink': 'Enlace no válido',
'pay.notFound': 'No encontramos este enlace de pago.',
'pay.payError': 'No se pudo completar el pago. Intenta de nuevo.',
'pay.simulatedNote': 'Modo de prueba: no se cobra dinero real.',
'pay.secureNote': 'Pago protegido por la pasarela de la clínica.',
'pay.state.pagado': 'Pagado',
// --- POS ---
'pos.title': 'Punto de Venta',
@@ -603,8 +634,42 @@ export const es: Dict = {
'agenda.noDoctorsSubWeek': 'No hay médicos dados de alta para mostrar en la vista semanal.',
'agenda.noResourcesTitle': 'Sin recursos',
'agenda.noResourcesSub': 'Agrega recursos en Configuración › Recursos para ver esta vista.',
'agenda.depositPending': 'Esperando anticipo',
'config.idiomaTitle': 'Idioma de la interfaz',
'config.idiomaBody': 'Elige el idioma con el que se muestra el sistema en este navegador.',
// Anticipo de reservas en línea
'config.anticipo.title': 'Anticipo al reservar',
'config.anticipo.help': 'Pide un depósito al reservar en línea. La reserva queda retenida sin confirmar hasta que se pague; si no se paga a tiempo, se libera el horario.',
'config.anticipo.enable': 'Requerir anticipo',
'config.anticipo.enableHelp': 'Apagado, el portal reserva sin pedir dinero (como hasta hoy).',
'config.anticipo.type': 'Tipo de anticipo',
'config.anticipo.typePercent': 'Porcentaje del servicio',
'config.anticipo.typeFixed': 'Monto fijo',
'config.anticipo.value': 'Valor (porcentaje o monto)',
'config.anticipo.currency': 'Moneda',
'config.anticipo.hours': 'Horas para pagar',
'config.anticipo.refundable': 'Reembolsable',
'config.anticipo.refundableHelp': 'Si se cancela la cita, el anticipo se devuelve.',
'config.anticipo.simulated': 'Permitir pago simulado',
'config.anticipo.simulatedHelp': 'Permite probar el flujo sin pasarela ni dinero real. Déjalo apagado en producción.',
'config.anticipo.notice': 'Aviso para el cliente',
'config.anticipo.noticePlaceholder': 'Se requiere un anticipo para confirmar tu cita.',
// Cobro automático de membresías
'config.cobroAuto.title': 'Cobro automático de membresías',
'config.cobroAuto.help': 'Genera el enlace de pago del periodo y lo envía al paciente por notificaciones, con reintentos. El periodo queda pendiente hasta que se pague.',
'config.cobroAuto.enable': 'Activar cobro automático',
'config.cobroAuto.enableHelp': 'Apagado, el proceso solo genera el adeudo (no cobra ni avisa).',
'config.cobroAuto.method': 'Método',
'config.cobroAuto.methodLink': 'Enlace de pago',
'config.cobroAuto.methodCard': 'Tarjeta guardada (Stripe)',
'config.cobroAuto.daysBefore': 'Días antes del vencimiento',
'config.cobroAuto.grace': 'Días de gracia',
'config.cobroAuto.retries': 'Días de reintento del aviso',
'config.cobroAuto.retriesHelp': 'Días (respecto al vencimiento) en que se reintenta avisar, separados por coma.',
'config.cobroAuto.cancel': 'Vencer si no paga',
'config.cobroAuto.cancelHelp': 'Al agotar los reintentos, marca la membresía vencida. Apagado, sigue activa hasta el proceso normal de vencimiento.',
'config.cobroAuto.simulated': 'Permitir pago simulado',
'config.cobroAuto.simulatedHelp': 'Permite probar el flujo sin pasarela ni dinero real. Déjalo apagado en producción.',
};
// ------------------------------------------------------------
@@ -953,6 +1018,8 @@ export const en: Dict = {
'reports.tab.cortes.desc': 'Daily counts and closings',
'reports.tab.adeudos': 'Debts',
'reports.tab.adeudos.desc': 'Accounts receivable and payment plans',
'reports.tab.cobros': 'Charges',
'reports.tab.cobros.desc': 'Deposits and memberships pending collection (pending and expired)',
'reports.tab.devoluciones': 'Refunds',
'reports.tab.devoluciones.desc': 'Returned sales and amounts',
'reports.tab.top-clientes': 'Top clients',
@@ -1052,6 +1119,32 @@ export const en: Dict = {
'public.manage.leadTime': ' (less than {h} h in advance)',
'public.manage.contactPhone': ' Contact us at {phone}.',
'public.manage.contactClinic': ' Contact the clinic.',
'public.awaitingDeposit': 'Booking held: deposit pending',
'public.depositAmount': 'Deposit due: {amount}',
'public.depositHelp': 'You have {h} hours to pay the deposit; otherwise the booking is released.',
'public.payDeposit': 'Pay deposit',
'public.depositExpires': 'The link expires in {h} hours',
'pay.title': 'Online payment',
'pay.folioWord': 'Reference',
'pay.concept': 'Concept',
'pay.amount': 'Amount',
'pay.forPatient': 'For {name}',
'pay.payNow': 'Pay now',
'pay.paySimulated': 'Confirm payment (test mode)',
'pay.paidOk': 'Payment recorded!',
'pay.receipt': 'Receipt',
'pay.date': 'Date',
'pay.notAvailable': 'Online payment is not available right now. Please contact the clinic.',
'pay.expiresAt': 'This link expires on {date}',
'pay.expiredBody': 'This link has expired. Please ask the clinic for a new one.',
'pay.cancelledBody': 'This link was cancelled.',
'pay.unavailableTitle': 'Link unavailable',
'pay.invalidLink': 'Invalid link',
'pay.notFound': 'We could not find this payment link.',
'pay.payError': 'The payment could not be completed. Please try again.',
'pay.simulatedNote': 'Test mode: no real money is charged.',
'pay.secureNote': 'Payment protected by the clinic payment gateway.',
'pay.state.pagado': 'Paid',
// --- POS ---
'pos.title': 'Point of Sale',
@@ -1168,8 +1261,40 @@ export const en: Dict = {
'agenda.noDoctorsSubWeek': 'No doctors are registered to show in the weekly view.',
'agenda.noResourcesTitle': 'No resources',
'agenda.noResourcesSub': 'Add resources in Settings › Resources to see this view.',
'agenda.depositPending': 'Deposit pending',
'config.idiomaTitle': 'Interface language',
'config.idiomaBody': 'Choose the language the system displays in this browser.',
'config.anticipo.title': 'Deposit at booking',
'config.anticipo.help': 'Ask for a deposit when booking online. The booking is held without confirming until paid; if unpaid in time, the slot is released.',
'config.anticipo.enable': 'Require deposit',
'config.anticipo.enableHelp': 'Off, the portal books without asking for money (as today).',
'config.anticipo.type': 'Deposit type',
'config.anticipo.typePercent': 'Percentage of the service',
'config.anticipo.typeFixed': 'Fixed amount',
'config.anticipo.value': 'Value (percentage or amount)',
'config.anticipo.currency': 'Currency',
'config.anticipo.hours': 'Hours to pay',
'config.anticipo.refundable': 'Refundable',
'config.anticipo.refundableHelp': 'If the appointment is cancelled, the deposit is refunded.',
'config.anticipo.simulated': 'Allow simulated payment',
'config.anticipo.simulatedHelp': 'Lets you test the flow with no gateway and no real money. Keep it off in production.',
'config.anticipo.notice': 'Customer notice',
'config.anticipo.noticePlaceholder': 'A deposit is required to confirm your appointment.',
'config.cobroAuto.title': 'Automatic membership charging',
'config.cobroAuto.help': 'Generates the payment link for the period and sends it to the patient via notifications, with retries. The period stays pending until paid.',
'config.cobroAuto.enable': 'Enable automatic charging',
'config.cobroAuto.enableHelp': 'Off, the process only creates the charge (no charge or notice).',
'config.cobroAuto.method': 'Method',
'config.cobroAuto.methodLink': 'Payment link',
'config.cobroAuto.methodCard': 'Saved card (Stripe)',
'config.cobroAuto.daysBefore': 'Days before due date',
'config.cobroAuto.grace': 'Grace days',
'config.cobroAuto.retries': 'Notice retry days',
'config.cobroAuto.retriesHelp': 'Days (relative to due date) on which the notice is retried, comma separated.',
'config.cobroAuto.cancel': 'Expire if unpaid',
'config.cobroAuto.cancelHelp': 'When retries are exhausted, marks the membership as expired. Off, it stays active until the normal expiration process.',
'config.cobroAuto.simulated': 'Allow simulated payment',
'config.cobroAuto.simulatedHelp': 'Lets you test the flow with no gateway and no real money. Keep it off in production.',
};
export const DICT: Record<Locale, Dict> = { es, en };

View File

@@ -23,6 +23,7 @@ export const PERM_KEYS: string[] = [
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
'cobros.ver', 'cobros.administrar', 'cobros.forzar', 'cobros.cancelar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
'api.claves', 'webhooks.editar',
];
@@ -53,6 +54,7 @@ const READ_PERM_KEYS = new Set([
'compras.ver',
'proveedores.ver',
'comisiones.ver',
'cobros.ver',
]);
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
@@ -84,6 +86,8 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'compras.editar': AG, 'compras.cancelar': AG,
'proveedores.ver': ALL, 'proveedores.editar': AG,
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
'cobros.ver': ALL, 'cobros.administrar': AG,
'cobros.forzar': ['admin', 'recepcion', 'gerencia'], 'cobros.cancelar': AG,
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
'api.claves': AD, 'webhooks.editar': AG,
};

View File

@@ -2882,7 +2882,12 @@ const Agenda: FC = () => {
</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
<td className="p-3">
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term, t)}</Badge>
<div className="flex flex-wrap items-center gap-1">
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term, t)}</Badge>
{apt.anticipo_estado === 'pendiente' && (
<Badge variant="warning">{t('agenda.depositPending')}</Badge>
)}
</div>
</td>
<td className="p-3 text-center">
<button
@@ -3369,6 +3374,9 @@ const Agenda: FC = () => {
{detailApt.is_first_visit && (
<Badge variant="primary">Primera vez</Badge>
)}
{detailApt.anticipo_estado === 'pendiente' && (
<Badge variant="warning">{t('agenda.depositPending')}</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-theme-bg rounded-xl">

View File

@@ -25,7 +25,7 @@ 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 { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { publicarClinicSettings } from '../lib/clinic-settings';
import { useI18n } from '../lib/i18n';
@@ -143,8 +143,17 @@ const Configuracion: FC = () => {
...CLINIC_SETTINGS_DEFAULT,
...next,
impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) },
reservas_online: { ...RESERVAS_ONLINE_DEFAULT, ...(next.reservas_online || {}) },
reservas_online: {
...RESERVAS_ONLINE_DEFAULT,
...(next.reservas_online || {}),
anticipo: { ...ANTICIPO_DEFAULT, ...((next.reservas_online || {}).anticipo || {}) },
},
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(next.notificaciones || {}) },
membresias: {
...MEMBRESIAS_DEFAULT,
...(next.membresias || {}),
cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT, ...((next.membresias || {}).cobro_automatico || {}) },
},
});
})
.catch(() => {

View File

@@ -0,0 +1,191 @@
import type { FC, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
AlertTriangle, Check, CreditCard, Loader2, Lock, ShieldCheck,
} from 'lucide-react';
import { Button } from '../components/ui';
import { odooApi, type CobroComprobante, type CobroPublico } from '../services/odoo';
import { useI18n } from '../lib/i18n';
// Página pública de pago de un enlace de cobro (anticipo de reserva o periodo
// de membresía). Anónima: el token HMAC del enlace es la única credencial.
// Funciona igual con pasarela real (redirige al checkout) o en modo simulado.
const Pagar: FC = () => {
const { token = '' } = useParams();
const { t } = useI18n();
const [cobro, setCobro] = useState<CobroPublico | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pagando, setPagando] = useState(false);
const [comprobante, setComprobante] = useState<CobroComprobante | null>(null);
useEffect(() => {
let alive = true;
if (!token) { setLoading(false); setError(t('pay.invalidLink')); return; }
odooApi.getCobroPublico(token)
.then((res) => { if (alive) setCobro(res); })
.catch((err) => {
const msg = (err as { response?: { data?: { message?: string } } });
if (alive) setError(msg?.response?.data?.message || t('pay.notFound'));
})
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [token]);
const pagar = async () => {
setPagando(true);
setError(null);
try {
const res = await odooApi.pagarCobroPublico(token);
if (res.requiere_pasarela && res.url) {
window.location.href = res.url;
return;
}
if (res.comprobante) {
setComprobante(res.comprobante);
setCobro((prev) => (prev ? { ...prev, estado: 'pagado', estado_label: t('pay.state.pagado') } : prev));
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
setError(msg || t('pay.payError'));
} finally {
setPagando(false);
}
};
if (loading) {
return <Centro><Loader2 className="h-6 w-6 animate-spin text-theme-muted" /></Centro>;
}
if (!cobro) {
return (
<Centro>
<Card>
<AlertTriangle className="mx-auto mb-3 h-8 w-8 text-amber-500" />
<h1 className="font-heading text-lg text-theme-heading">{t('pay.unavailableTitle')}</h1>
<p className="mt-2 text-sm text-theme-muted">{error || t('pay.expiredBody')}</p>
</Card>
</Centro>
);
}
const pagado = comprobante || cobro.estado === 'pagado';
const montoTxt = `${cobro.moneda} ${cobro.monto.toFixed(2)}`;
return (
<Centro>
<div className="w-full max-w-md space-y-4">
<header className="text-center">
{cobro.brand?.logo_oscuro && (
<img src={cobro.brand.logo_oscuro} alt={cobro.brand.nombre}
className="mx-auto mb-3 h-9 w-auto object-contain" />
)}
<h1 className="font-heading text-lg text-theme-heading">{cobro.brand?.nombre || ''}</h1>
</header>
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 shadow-soft">
<div className="flex items-center justify-between">
<h2 className="font-heading text-base text-theme-heading">{t('pay.title')}</h2>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${badgeClase(pagado ? 'pagado' : cobro.estado)}`}>
{pagado ? t('pay.state.pagado') : cobro.estado_label}
</span>
</div>
<p className="mt-1 text-sm text-theme-muted">
{t('pay.folioWord')} <span className="font-semibold text-theme-heading">{cobro.folio}</span>
</p>
<div className="mt-5 rounded-2xl bg-theme-bg p-4">
<p className="text-xs uppercase tracking-wide text-theme-muted">{t('pay.concept')}</p>
<p className="mt-0.5 text-sm text-theme-heading">{cobro.concepto}</p>
<div className="mt-3 flex items-end justify-between">
<span className="text-xs uppercase tracking-wide text-theme-muted">{t('pay.amount')}</span>
<span className="font-heading text-2xl text-theme-heading">{montoTxt}</span>
</div>
{cobro.paciente && (
<p className="mt-2 text-xs text-theme-muted">{t('pay.forPatient', { name: cobro.paciente })}</p>
)}
</div>
{/* Estado pagado: comprobante */}
{pagado && (
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-center">
<Check className="mx-auto mb-2 h-7 w-7 text-emerald-600" />
<p className="text-sm font-medium text-emerald-800">{t('pay.paidOk')}</p>
<div className="mt-3 space-y-1 text-left text-xs text-emerald-800">
<p>{t('pay.receipt')}: <span className="font-semibold">{comprobante?.pago || cobro.folio}</span></p>
<p>{t('pay.concept')}: {comprobante?.concepto || cobro.concepto}</p>
<p>{t('pay.amount')}: {montoTxt}</p>
{comprobante?.fecha && <p>{t('pay.date')}: {String(comprobante.fecha).slice(0, 16)}</p>}
</div>
</div>
)}
{/* Pendiente: botón de pago */}
{!pagado && cobro.estado === 'pendiente' && (
<>
{cobro.puede_pagar ? (
<Button className="mt-5 w-full" loading={pagando} onClick={pagar}>
<CreditCard size={16} className="mr-2" />
{cobro.simulado ? t('pay.paySimulated') : t('pay.payNow')}
</Button>
) : (
<p className="mt-5 rounded-xl bg-amber-50 p-3 text-sm text-amber-800">
{t('pay.notAvailable')}
</p>
)}
{cobro.expira && (
<p className="mt-3 text-center text-xs text-theme-muted">
{t('pay.expiresAt', { date: String(cobro.expira).slice(0, 16) })}
</p>
)}
</>
)}
{(cobro.estado === 'expirado' || cobro.estado === 'cancelado') && !pagado && (
<p className="mt-5 rounded-xl bg-rose-50 p-3 text-sm text-rose-700">
{cobro.estado === 'expirado' ? t('pay.expiredBody') : t('pay.cancelledBody')}
</p>
)}
{error && <p className="mt-3 text-sm text-rose-600">{error}</p>}
{cobro.simulado && !pagado && (
<p className="mt-4 flex items-center justify-center gap-1 text-xs text-theme-muted">
<ShieldCheck size={13} /> {t('pay.simulatedNote')}
</p>
)}
{!cobro.simulado && (
<p className="mt-4 flex items-center justify-center gap-1 text-xs text-theme-muted">
<Lock size={13} /> {t('pay.secureNote')}
</p>
)}
</div>
</div>
</Centro>
);
};
const badgeClase = (estado: string): string => {
switch (estado) {
case 'pagado': return 'bg-emerald-100 text-emerald-700';
case 'pendiente': return 'bg-amber-100 text-amber-700';
case 'expirado': return 'bg-rose-100 text-rose-700';
default: return 'bg-theme-bg text-theme-muted';
}
};
const Centro: FC<{ children: ReactNode }> = ({ children }) => (
<div className="flex min-h-screen items-start justify-center bg-theme-bg px-4 py-8 sm:items-center">
{children}
</div>
);
const Card: FC<{ children: ReactNode }> = ({ children }) => (
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
{children}
</div>
);
export default Pagar;

View File

@@ -37,6 +37,7 @@ import KpisReport from '../components/reportes/KpisReport';
import RecursosReport from '../components/reportes/RecursosReport';
import ComprasReport from '../components/reportes/ComprasReport';
import MargenReport from '../components/reportes/MargenReport';
import CobrosReport from '../components/reportes/CobrosReport';
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { CommissionRow } from '../services/odoo';
@@ -82,6 +83,7 @@ const FAMILIAS: ReportFamily[] = [
{ key: 'impuestos', label: 'Impuestos', desc: 'Base e impuesto del periodo por mes, servicio y sucursal' },
{ key: 'cortes', label: 'Cortes de caja', desc: 'Arqueos y cierres por día' },
{ key: 'adeudos', label: 'Adeudos', desc: 'Cartera por cobrar y planes de pago' },
{ key: 'cobros', label: 'Cobros', desc: 'Anticipos y membresías por cobrar (pendientes y expirados)' },
{ key: 'devoluciones', label: 'Devoluciones', desc: 'Ventas devueltas y montos' },
],
},
@@ -478,6 +480,8 @@ const ReportesContent: FC = () => {
<MargenReport />
) : tab === 'adeudos' ? (
<AdeudosReport />
) : tab === 'cobros' ? (
<CobrosReport />
) : tab === 'comisiones' ? (
<ComisionesReport />
) : tab === 'pagos-servicios' ? (

View File

@@ -158,11 +158,15 @@ const Reservar: FC = () => {
<Centro>
<div className="w-full max-w-lg space-y-4">
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 text-center shadow-soft">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100">
<Check className="h-6 w-6 text-emerald-600" />
<div className={`mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full ${resultado.esperando_anticipo ? 'bg-amber-100' : 'bg-emerald-100'}`}>
{resultado.esperando_anticipo
? <Clock className="h-6 w-6 text-amber-600" />
: <Check className="h-6 w-6 text-emerald-600" />}
</div>
<h1 className="font-heading text-xl text-theme-heading">
{resultado.requiere_confirmacion ? t('public.requestReceived') : t('public.confirmed')}
{resultado.esperando_anticipo
? t('public.awaitingDeposit')
: (resultado.requiere_confirmacion ? t('public.requestReceived') : t('public.confirmed'))}
</h1>
<p className="mt-1 text-sm text-theme-muted">{t('public.folioWord')} <span className="font-semibold text-theme-heading">{resultado.folio}</span></p>
<div className="mt-5 space-y-2 rounded-2xl bg-theme-bg p-4 text-left text-sm">
@@ -171,10 +175,28 @@ const Reservar: FC = () => {
{resultado.medico && <Linea icon={<User className="h-4 w-4" />} texto={resultado.medico} />}
{resultado.sucursal && <Linea icon={<MapPin className="h-4 w-4" />} texto={resultado.sucursal} />}
</div>
{resultado.mensaje_confirmacion && (
{resultado.esperando_anticipo && resultado.anticipo && (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-left">
<p className="text-sm font-medium text-amber-800">
{t('public.depositAmount', {
amount: `${resultado.anticipo.moneda} ${resultado.anticipo.monto.toFixed(2)}`,
})}
</p>
<p className="mt-1 text-xs text-amber-700">
{resultado.mensaje_anticipo || t('public.depositHelp', { h: resultado.anticipo.horas_para_pagar })}
</p>
<Button className="mt-3 w-full" onClick={() => navigate(`/pagar/${resultado.anticipo!.token}`)}>
{t('public.payDeposit')}
</Button>
<p className="mt-2 text-center text-xs text-amber-700">
{t('public.depositExpires', { h: resultado.anticipo.horas_para_pagar })}
</p>
</div>
)}
{!resultado.esperando_anticipo && resultado.mensaje_confirmacion && (
<p className="mt-4 text-sm text-theme-muted">{resultado.mensaje_confirmacion}</p>
)}
{resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (
{!resultado.esperando_anticipo && resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (
<p className="mt-4 text-sm text-theme-muted">
{t('public.willConfirm')}
</p>

View File

@@ -18,9 +18,14 @@ axios.interceptors.response.use(
(res) => res,
(error) => {
if (error?.response?.status === 401) {
const onLogin = window.location.pathname === '/login';
const path = window.location.pathname;
// Rutas públicas (sin sesión): un 401 de un endpoint autenticado no debe
// expulsar al visitante a /login. Cubre el portal de reservas, la página
// de pago y el portal de formularios.
const PUBLICAS = ['/login', '/reservar', '/pagar', '/formulario'];
const onPublica = PUBLICAS.some((p) => path === p || path.startsWith(p + '/'));
const isLoginCall = String(error.config?.url || '').includes('/auth/login');
if (!onLogin && !isLoginCall) {
if (!onPublica && !isLoginCall) {
localStorage.removeItem('skeen_token');
localStorage.removeItem('skeen_user');
window.location.href = '/login';
@@ -92,6 +97,10 @@ export interface Appointment {
time_float?: number;
/** Etiqueta del estado con el vocabulario de la clínica */
state_label?: string;
/** Anticipo de reserva en línea: 'ninguno' | 'pendiente' | 'pagado' | 'reembolsado' */
anticipo_estado?: string;
anticipo_estado_label?: string;
anticipo_monto?: number;
}
/** Motivos predefinidos de un bloqueo de agenda */
@@ -1213,6 +1222,33 @@ export interface WebhookEventoCatalogo {
}
// ---- Reservas en línea (portal público de auto-agendado) ----
export interface AnticipoSettings {
/** Enciende el anticipo al reservar en línea. Apagado = nada cambia. */
activo: boolean;
tipo: 'porcentaje' | 'monto_fijo';
/** Porcentaje (%) o monto fijo según `tipo`. */
valor: number;
moneda: string;
/** Horas que tiene el cliente para pagar antes de liberar el horario. */
horas_para_pagar: number;
reembolsable: boolean;
/** Texto que el portal muestra al pedir el anticipo. */
texto_aviso: string;
/** Permite confirmar el pago en modo simulado (sin pasarela). */
permitir_simulado: boolean;
}
export const ANTICIPO_DEFAULT: AnticipoSettings = {
activo: false,
tipo: 'porcentaje',
valor: 0,
moneda: 'MXN',
horas_para_pagar: 24,
reembolsable: true,
texto_aviso: '',
permitir_simulado: false,
};
export interface ReservasOnlineSettings {
activo: boolean;
titulo: string;
@@ -1226,6 +1262,7 @@ export interface ReservasOnlineSettings {
cancelar_hasta_horas: number;
mensaje_confirmacion: string;
telefono_contacto: string;
anticipo: AnticipoSettings;
}
export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
@@ -1241,6 +1278,7 @@ export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
cancelar_hasta_horas: 24,
mensaje_confirmacion: '',
telefono_contacto: '',
anticipo: { ...ANTICIPO_DEFAULT },
};
// ---- Notificaciones y recordatorios de citas ----
@@ -1309,6 +1347,8 @@ export interface NotificacionesSettings {
plantilla_post_visita: string;
/** Aviso de un formulario digital: usa {formulario} y {enlace}. */
plantilla_formulario: string;
/** Aviso con enlace de pago del periodo (cobro automático de membresías). */
plantilla_cobro_membresia: string;
whatsapp_numero: string;
email_remitente: string;
}
@@ -1328,12 +1368,13 @@ export const NOTIFICACIONES_DEFAULT: NotificacionesSettings = {
plantilla_cumpleanos: '¡Feliz cumpleaños, {nombre}! Todo el equipo de {sucursal} te desea un gran día. Te esperamos pronto.',
plantilla_post_visita: 'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes dudas o quieres agendar de nuevo, llámanos al {telefono}.',
plantilla_formulario: 'Hola {nombre}, te compartimos el formulario "{formulario}" para completar. Ábrelo desde tu celular aquí: {enlace}',
plantilla_cobro_membresia: 'Hola {nombre}, tu plan {plan} ({periodo}) está por renovarse. Paga {monto} desde este enlace: {enlace}',
whatsapp_numero: '',
email_remitente: '',
};
/** Variables permitidas en las plantillas (espejo del backend). */
export const NOTIF_VARIABLES = ['nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', 'sucursal', 'telefono', 'enlace', 'formulario'] as const;
export const NOTIF_VARIABLES = ['nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', 'sucursal', 'telefono', 'enlace', 'formulario', 'plan', 'membresia', 'monto', 'sesiones', 'periodo'] as const;
/** Ventanas sugeridas para los chips de recordatorio. */
export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
@@ -1345,6 +1386,31 @@ export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
];
// ---- Membresías y suscripciones ----
export interface CobroAutomaticoSettings {
/** Enciende el cobro automático del periodo de membresía. */
activo: boolean;
/** Días antes del vencimiento en que se envía el primer aviso/enlace. */
dias_antes: number;
metodo: 'enlace' | 'tarjeta_guardada';
/** Días (respecto al vencimiento) en que se reintenta el aviso. */
reintentos_dias: number[];
/** Al agotar reintentos, marca la membresía vencida (si no, sigue activa). */
cancelar_si_no_paga: boolean;
dias_gracia: number;
/** Permite confirmar el pago en modo simulado (sin pasarela). */
permitir_simulado: boolean;
}
export const COBRO_AUTOMATICO_DEFAULT: CobroAutomaticoSettings = {
activo: false,
dias_antes: 3,
metodo: 'enlace',
reintentos_dias: [1, 3, 7],
cancelar_si_no_paga: false,
dias_gracia: 5,
permitir_simulado: false,
};
export interface MembresiasSettings {
/** Enciende TODO el módulo: menú, POS, cron y cargos. Apagado = no cambia nada. */
activo: boolean;
@@ -1361,6 +1427,8 @@ export interface MembresiasSettings {
generar_cargo_al_renovar: boolean;
/** Días tras el vencimiento antes de marcar la membresía vencida. */
dias_gracia: number;
/** Cobro automático del periodo con enlace de pago. */
cobro_automatico: CobroAutomaticoSettings;
}
export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
@@ -1373,6 +1441,7 @@ export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
permitir_pausa_pago: false,
generar_cargo_al_renovar: true,
dias_gracia: 5,
cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT },
};
// ---- Compras y proveedores ----
@@ -2042,6 +2111,16 @@ export interface BookingConfig {
descripcion: string;
mensaje_confirmacion: string;
zona_horaria?: string;
/** Anticipo del portal (apagado por defecto). */
anticipo?: {
activo: boolean;
tipo: 'porcentaje' | 'monto_fijo';
valor: number;
moneda: string;
horas_para_pagar: number;
reembolsable: boolean;
texto_aviso: string;
};
};
}
@@ -2070,6 +2149,19 @@ export interface BookingCreateResult {
requiere_confirmacion: boolean;
mensaje_confirmacion: string;
token_de_gestion: string;
/** Con anticipo activo: la cita queda retenida esperando el pago. */
esperando_anticipo?: boolean;
mensaje_anticipo?: string;
anticipo?: {
monto: number;
moneda: string;
horas_para_pagar: number;
reembolsable: boolean;
url: string;
token: string;
expira: string;
proveedor: string;
};
}
export interface BookingCita {
@@ -2088,6 +2180,63 @@ export interface BookingCita {
telefono_contacto: string;
}
// ---- Enlaces de cobro (anticipos y membresías) ----
export interface CobroPublico {
status: string;
folio: string;
concepto: string;
monto: number;
moneda: string;
estado: 'pendiente' | 'pagado' | 'expirado' | 'cancelado';
estado_label: string;
origen: 'reserva' | 'membresia' | 'venta';
proveedor: 'simulado' | 'stripe' | 'mercadopago';
expira: string | null;
fecha_pago: string | null;
paciente: string;
puede_pagar: boolean;
simulado: boolean;
url_externa: string;
brand: ClinicBrand;
terms: GlossaryTerms;
zona_horaria?: string;
}
export interface CobroComprobante {
folio: string;
concepto: string;
monto: number;
moneda: string;
fecha: string;
proveedor: string;
pago: string;
}
export interface Cobro {
id: number;
nombre: string;
origen: string;
origen_label: string;
estado: string;
estado_label: string;
proveedor: string;
partner_id: number | null;
patient: string;
cita_id: number | null;
folio_cita: string;
venta_id: number | null;
concepto: string;
monto: number;
moneda: string;
creado: string | null;
expira: string | null;
fecha_pago: string | null;
recordatorios: number;
url: string;
vencido?: boolean;
dias_para_vencer?: number;
}
// ---- Formularios digitales y consentimientos ----
export type FormularioCampoTipo =
| 'texto' | 'texto_largo' | 'numero' | 'fecha' | 'seleccion' | 'multi_seleccion'
@@ -3298,6 +3447,46 @@ export const odooApi = {
return data;
},
// ---- Enlaces de cobro ----
// Público (sin sesión): consultar y pagar un enlace por token.
async getCobroPublico(token: string): Promise<CobroPublico> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/cobro`, { params: { token } });
return data;
},
async pagarCobroPublico(token: string, hp?: string): Promise<{
status: string; ok?: boolean; estado?: string; estado_label?: string;
requiere_pasarela?: boolean; url?: string; proveedor?: string;
comprobante?: CobroComprobante; message?: string;
}> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/public/cobro/pagar`, { token, hp });
return data;
},
// Personal (con sesión).
async listCobros(params?: { estado?: string; origen?: string }): Promise<{ status: string; cobros: Cobro[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/cobros`, { params });
return data;
},
async forzarCobro(id: number, payload?: { proveedor?: string; referencia?: string }): Promise<{ status: string; cobro: Cobro; pago: string; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/cobros/${id}/forzar`, payload || {});
return data;
},
async cancelarCobro(id: number, motivo?: string): Promise<{ status: string; cobro: Cobro; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/cobros/${id}/cancelar`, { motivo });
return data;
},
async getCobrosReport(params: { start?: string; end?: string }): Promise<{
status: string; start: string | null; end: string | null; cobros: Cobro[];
total: number; resumen: { pendiente: number; expirado: number; cancelado: number }; message?: string;
}> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/cobros`, { params });
return data;
},
// ---- Formularios digitales: plantillas ----
async getFormularioPlantillas(opts?: { includeInactive?: boolean; tipo?: string }): Promise<{ status: string; plantillas: FormularioPlantilla[]; total: number; message?: string }> {
const params: Record<string, string | number> = {};