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:
@@ -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>} />
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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">
|
||||
|
||||
183
frontend/src/components/reportes/CobrosReport.tsx
Normal file
183
frontend/src/components/reportes/CobrosReport.tsx
Normal 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;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
191
frontend/src/pages/Pagar.tsx
Normal file
191
frontend/src/pages/Pagar.tsx
Normal 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;
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
Reference in New Issue
Block a user