fase 2.6: membresias y suscripciones (modulo skeen_membresias: planes recurrentes con sesiones incluidas o ilimitadas, alta/cobro/renovacion/congelamiento/cancelacion, consumo de sesiones desde POS y expediente, cron de vencimientos y avisos, reporte de ingresos recurrentes); apagado por defecto

This commit is contained in:
2026-10-11 10:49:44 +00:00
parent 9286ab8ee2
commit 8ff13b9c2a
33 changed files with 3873 additions and 22 deletions

View File

@@ -10,6 +10,7 @@ import { ServiceCategoriesProvider } from './lib/service-categories';
import { QuickLinksProvider } from './lib/quicklinks';
import { PatientDrawerProvider } from './components/PatientDrawer';
import ChatNotifications from './lib/chat-notifications';
import { useClinicSettings } from './lib/clinic-settings';
import Dashboard from './pages/Dashboard';
import Agenda from './pages/Agenda';
import Pacientes from './pages/Pacientes';
@@ -18,6 +19,7 @@ import Visitas from './pages/Visitas';
import Consultas from './pages/Consultas';
import Servicios from './pages/Servicios';
import Paquetes from './pages/Paquetes';
import Membresias from './pages/Membresias';
import Medicos from './pages/Medicos';
import Monedero from './pages/Monedero';
import Pagos from './pages/Pagos';
@@ -109,6 +111,15 @@ const LANDING_CANDIDATOS = [
'/wacrm/leads', '/configuracion',
];
// Membresías vive detrás de `membresias.activo`. Si la clínica la apagó, la
// ruta rebota a "/" aunque el rol tenga permiso de menú (así apagarlo vuelve
// todo al estado anterior, incluso entrando por URL).
const MembresiasRoute: FC = () => {
const { membresias } = useClinicSettings();
if (!membresias.activo) return <Navigate to="/" replace />;
return <Membresias />;
};
const HomeRoute: FC = () => {
const { user, canSee } = useAuth();
if (user?.role === 'medico') return <Navigate to="/agenda" replace />;
@@ -155,6 +166,8 @@ const App: FC = () => {
<Route path="/servicios" element={<RequireAuth href="/servicios"><Servicios /></RequireAuth>} />
<Route path="/medicos" element={<RequireAuth href="/medicos"><Medicos /></RequireAuth>} />
<Route path="/paquetes" element={<RequireAuth href="/paquetes"><Paquetes /></RequireAuth>} />
{/* Membresías solo cuando la clínica la activa (membresias.activo). */}
<Route path="/membresias" element={<RequireAuth href="/membresias"><MembresiasRoute /></RequireAuth>} />
<Route path="/productos" element={<Navigate to="/inventario" replace />} />
<Route path="/monedero" element={<RequireAuth href="/monedero"><Monedero /></RequireAuth>} />
<Route path="/pagos" element={<RequireAuth href="/pagos"><Pagos /></RequireAuth>} />

View File

@@ -30,6 +30,7 @@ import {
DollarSign,
CircleDollarSign,
TicketCheck,
Repeat,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -37,6 +38,7 @@ import GlobalSearch from './GlobalSearch';
import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell';
import { useAuth, ROLE_LABELS } from '../lib/auth';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
@@ -140,21 +142,32 @@ const menuTree: MenuEntry[] = [
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
];
// Hojas planas (para accesos rápidos y dock)
const menuLeaves = menuTree.flatMap((e) =>
e.children ? e.children.map((c) => ({ label: c.label, icon: e.icon, href: c.href })) : [{ label: e.label, icon: e.icon, href: e.href as string }]
);
export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { membresias: membresiasCfg } = useClinicSettings();
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
// Membresías solo cuando la clínica enciende `membresias.activo`.
const menuTreeT = useMemo<MenuEntry[]>(
() => (membresiasCfg.activo
? [...menuTree, { label: 'Membresías', icon: Repeat, href: '/membresias' }]
: menuTree),
[membresiasCfg.activo]
);
const menuLeavesT = useMemo(
() => menuTreeT.flatMap((e) =>
e.children
? e.children.map((c) => ({ label: c.label, icon: e.icon, href: c.href }))
: [{ label: e.label, icon: e.icon, href: e.href as string }]),
[menuTreeT]
);
const visibleLeaves = useMemo(
() => menuLeaves.filter((it) => canSee(it.href)),
[canSee]
() => menuLeavesT.filter((it) => canSee(it.href)),
[canSee, menuLeavesT]
);
// Accesos rápidos personalizables (localStorage por usuario)
@@ -164,9 +177,9 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
() =>
quick.links
.filter((href) => canSee(href))
.map((href) => menuLeaves.find((it) => it.href === href) ?? null)
.filter((it): it is (typeof menuLeaves)[number] => it !== null),
[quick.links, canSee]
.map((href) => menuLeavesT.find((it) => it.href === href) ?? null)
.filter((it): it is (typeof menuLeavesT)[number] => it !== null),
[quick.links, canSee, menuLeavesT]
);
const initials = (user?.name || 'US')
@@ -251,7 +264,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
// Grupos de submenú abiertos (auto-abre el de la ruta activa)
const [openGroups, setOpenGroups] = useState<string[]>(() => {
const current = `${location.pathname}${location.search}`;
return menuTree
return menuTreeT
.filter((e) => e.children?.some((c) => current.startsWith(c.href.split('?')[0]) && (!c.href.includes('?') || current.includes(c.href.split('?')[1]))))
.map((e) => e.label);
});
@@ -269,7 +282,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
// 1) El grupo que contiene la ruta activa siempre queda abierto
useEffect(() => {
const current = `${location.pathname}${location.search}`;
const abiertos = menuTree
const abiertos = menuTreeT
.filter((e) => e.children?.some((c) => {
const base = c.href.split('?')[0];
return current === c.href || (current.startsWith(base) && (!c.href.includes('?') || current.includes(c.href.split('?')[1])));
@@ -361,7 +374,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
</div>
)}
<ul>
{menuTree.map((entry) => {
{menuTreeT.map((entry) => {
const Icon = entry.icon;
// Hojas visibles de este entry
const leaves = entry.children

View File

@@ -36,6 +36,7 @@ import {
TicketCheck,
Layers,
Download,
Repeat,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -44,6 +45,7 @@ import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell';
import { useAuth } from '../lib/auth';
import { useGlosario, traducirMenu, traducirEtiqueta } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
@@ -110,16 +112,26 @@ const MENU_SECTIONS = [
},
];
const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const { membresias: membresiasCfg } = useClinicSettings();
const topNavItems = useMemo(
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
[term]
);
const menuSections = useMemo(() => traducirMenu(MENU_SECTIONS, term), [term]);
const menuSections = useMemo(() => {
const base = membresiasCfg.activo
? MENU_SECTIONS.map((s) => (s.label === 'Clínica'
? { ...s, items: [...s.items, HOME_MEMBRESIA_ITEM] }
: s))
: MENU_SECTIONS;
return traducirMenu(base, term);
}, [term, membresiasCfg.activo]);
const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
const chatUnread = useChatUnread();

View File

@@ -0,0 +1,223 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Repeat, Plus } from 'lucide-react';
import { Button, Input, Select, Modal, Badge, toast } from './ui';
import { odooApi, type Membresia, type MembresiaPlan } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings';
// Bloque "Membresías" del expediente del paciente: membresías activas, saldo de
// sesiones por periodo y acciones (alta, usar sesión, cobrar, renovar). Solo se
// usa cuando la clínica tiene `membresias.activo=true`.
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
const ESTADO_CLS: Record<string, string> = {
activa: 'bg-emerald-100 text-emerald-700 border-emerald-200',
congelada: 'bg-sky-100 text-sky-700 border-sky-200',
vencida: 'bg-amber-100 text-amber-700 border-amber-200',
pausada: 'bg-theme-bg text-theme-muted border-theme-border',
cancelada: 'bg-rose-100 text-rose-700 border-rose-200',
};
interface Props {
partnerId: number;
}
const MembresiasPaciente: FC<Props> = ({ partnerId }) => {
const { can } = useAuth();
const { term } = useGlosario();
const { membresias: cfg } = useClinicSettings();
const [membresias, setMembresias] = useState<Membresia[]>([]);
const [planes, setPlanes] = useState<MembresiaPlan[]>([]);
const [cargando, setCargando] = useState(false);
const [nuevaOpen, setNuevaOpen] = useState(false);
const [nueva, setNueva] = useState({ plan_id: '', fecha_inicio: '', renovacion_automatica: false, descuento: '' });
const [guardando, setGuardando] = useState(false);
const [consumo, setConsumo] = useState<{ m: Membresia; servicio_id: string } | null>(null);
const cargar = useCallback(async () => {
if (!partnerId) return;
try {
setCargando(true);
const res = await odooApi.getMembresiasPorPaciente(partnerId);
if (res.status === 'success') setMembresias(res.membresias);
} catch {
/* silencioso: el bloque es informativo */
} finally {
setCargando(false);
}
}, [partnerId]);
useEffect(() => { cargar(); }, [cargar]);
const abrirNueva = async () => {
if (planes.length === 0) {
try {
const res = await odooApi.getMembresiaPlanes();
if (res.status === 'success') setPlanes(res.planes);
} catch { /* ignore */ }
}
setNueva({ plan_id: '', fecha_inicio: new Date().toISOString().slice(0, 10), renovacion_automatica: cfg.renovacion_automatica_default, descuento: '' });
setNuevaOpen(true);
};
const crear = async () => {
if (!nueva.plan_id) { toast.error('Selecciona un plan'); return; }
try {
setGuardando(true);
const res = await odooApi.crearMembresia({
partner_id: partnerId,
plan_id: parseInt(nueva.plan_id, 10),
fecha_inicio: nueva.fecha_inicio || undefined,
renovacion_automatica: nueva.renovacion_automatica,
...(nueva.descuento !== '' ? { descuento_porcentaje: parseFloat(nueva.descuento) } : {}),
});
if (res.status !== 'success') { toast.error(res.message || 'No se pudo crear'); return; }
toast.success('Membresía creada');
setNuevaOpen(false);
cargar();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al crear la membresía');
} finally {
setGuardando(false);
}
};
const usarSesion = async () => {
if (!consumo || !consumo.servicio_id) { toast.error('Selecciona el servicio'); return; }
try {
const res = await odooApi.consumirMembresia(consumo.m.id, {
sesiones: 1, servicio_id: parseInt(consumo.servicio_id, 10),
notas: 'Consumido desde el expediente',
});
if (res.status !== 'success') { toast.error(res.message || 'No se pudo consumir'); return; }
toast.success('Sesión de membresía registrada');
setConsumo(null);
cargar();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al consumir la sesión');
}
};
if (!cfg.activo) return null;
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-theme-heading flex items-center gap-2">
<Repeat size={15} /> Membresías
</h4>
{can('membresias.crear') && (
<Button variant="outline" size="sm" onClick={abrirNueva}>
<Plus size={13} className="mr-1" /> Nueva
</Button>
)}
</div>
{cargando ? (
<p className="text-xs text-theme-muted">Cargando…</p>
) : membresias.length === 0 ? (
<p className="text-xs text-theme-muted">Sin membresías activas.</p>
) : (
<ul className="space-y-2">
{membresias.map((m) => (
<li key={m.id} className="rounded-xl border border-theme-border p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{m.plan}</p>
<p className="text-[11px] text-theme-muted">
{m.folio} · {m.periodo_actual_inicio} a {m.periodo_actual_fin}
{m.proximo_cobro ? ` · próximo cobro ${m.proximo_cobro}` : ''}
</p>
</div>
<span className={`inline-block text-[11px] font-medium px-2 py-0.5 rounded-full border ${ESTADO_CLS[m.estado] || ESTADO_CLS.pausada}`}>
{m.estado_label}
</span>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<p className="text-xs text-theme-muted">
{m.ilimitadas
? 'Sesiones ilimitadas'
: `${m.sesiones_usadas_periodo}/${m.sesiones_incluidas_periodo} sesiones · quedan ${m.sesiones_restantes}`}
{m.adeudo > 0 && <span className="text-amber-700 font-medium"> · adeudo {fmtMoney(m.adeudo)}</span>}
</p>
{m.estado === 'activa' && can('membresias.cobrar') && !m.ilimitadas && m.sesiones_restantes > 0 && m.servicios_incluidos.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setConsumo({ m, servicio_id: String(m.servicios_incluidos[0]?.id || '') })}>
Usar sesión
</Button>
)}
</div>
</li>
))}
</ul>
)}
<Modal isOpen={nuevaOpen} onClose={() => setNuevaOpen(false)} title={`Nueva membresía para el ${term('patient_singular').toLowerCase()}`}>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Plan *</label>
<Select
options={[{ value: '', label: 'Selecciona un plan…' },
...planes.map((p) => ({ value: String(p.id), label: `${p.name} · ${fmtMoney(p.precio)} / ${p.periodicidad}` }))]}
value={nueva.plan_id} onChange={(e) => setNueva({ ...nueva, plan_id: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Inicio</label>
<Input type="date" value={nueva.fecha_inicio} onChange={(e) => setNueva({ ...nueva, fecha_inicio: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Descuento (%)</label>
<Input type="number" min={0} max={100} value={nueva.descuento}
onChange={(e) => setNueva({ ...nueva, descuento: e.target.value })} placeholder="Por defecto del plan" />
</div>
</div>
<label className="flex items-center gap-2 text-sm text-theme-heading">
<input type="checkbox" checked={nueva.renovacion_automatica}
onChange={(e) => setNueva({ ...nueva, renovacion_automatica: e.target.checked })}
className="rounded border-theme-border-strong" />
Renovación automática
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setNuevaOpen(false)}>Cancelar</Button>
<Button onClick={crear} disabled={guardando}>{guardando ? 'Guardando…' : 'Crear membresía'}</Button>
</div>
</div>
</Modal>
<Modal isOpen={!!consumo} onClose={() => setConsumo(null)} title="Usar sesión de membresía">
{consumo && (
<div className="space-y-3">
<p className="text-sm text-theme-muted">
Se descontará 1 sesión del periodo. {consumo.m.ilimitadas ? 'El plan es ilimitado.' : `Quedan ${consumo.m.sesiones_restantes}.`}
</p>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Servicio</label>
<Select
options={consumo.m.servicios_incluidos.map((s) => ({ value: String(s.id), label: s.name }))}
value={consumo.servicio_id}
onChange={(e) => setConsumo({ ...consumo, servicio_id: e.target.value })} />
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setConsumo(null)}>Cancelar</Button>
<Button onClick={usarSesion}>Registrar consumo</Button>
</div>
</div>
)}
</Modal>
{membresias.some((m) => m.estado !== 'activa') && (
<div className="pt-1">
<Badge variant="warning">Hay membresías no activas; revísalas en el módulo Membresías.</Badge>
</div>
)}
</div>
);
};
export default MembresiasPaciente;

View File

@@ -0,0 +1,456 @@
import type { FC } from 'react';
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,
type Service, type ServiceCategory, type PeriodicidadMembresia,
} from '../services/odoo';
// 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
// cambia; este panel es la llave para encender el módulo.
interface Props {
settings: ClinicSettings;
onSaved: (settings: ClinicSettings) => void;
}
const PERIODICIDADES: { value: PeriodicidadMembresia; label: string }[] = [
{ value: 'semanal', label: 'Semanal' },
{ value: 'mensual', label: 'Mensual' },
{ value: 'trimestral', label: 'Trimestral' },
{ value: 'semestral', label: 'Semestral' },
{ value: 'anual', label: 'Anual' },
];
interface FormPlan {
id?: number;
name: string;
periodicidad: PeriodicidadMembresia;
precio: string;
moneda: 'MXN' | 'USD';
sesiones_incluidas: string;
descuento_porcentaje: string;
cargo_inscripcion: string;
meses_minimos: string;
permitir_congelamiento: boolean;
activo: boolean;
servicio_id: string;
servicios_incluidos: number[];
categorias_incluidas: number[];
descripcion: string;
notas: string;
}
const emptyForm: FormPlan = {
name: '', periodicidad: 'mensual', precio: '', moneda: 'MXN',
sesiones_incluidas: '0', descuento_porcentaje: '0', cargo_inscripcion: '0',
meses_minimos: '0', permitir_congelamiento: true, activo: true,
servicio_id: '', servicios_incluidos: [], categorias_incluidas: [],
descripcion: '', notas: '',
};
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
<label className="flex items-start gap-3 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
<span>
<span className="block text-sm font-medium text-theme-heading">{label}</span>
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
</span>
</label>
);
const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
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(', '));
const [planes, setPlanes] = useState<MembresiaPlan[]>([]);
const [servicios, setServicios] = useState<Service[]>([]);
const [categorias, setCategorias] = useState<ServiceCategory[]>([]);
const [cargandoPlanes, setCargandoPlanes] = useState(false);
const [mostrarBaja, setMostrarBaja] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [planForm, setPlanForm] = useState<FormPlan>(emptyForm);
const [guardandoPlan, setGuardandoPlan] = useState(false);
useEffect(() => {
setForm(settings.membresias || MEMBRESIAS_DEFAULT);
setAvisos((settings.membresias?.dias_aviso_renovacion || [7, 1]).join(', '));
}, [settings.membresias]);
useEffect(() => {
odooApi.getServices({ page_size: 500 })
.then((res) => { if (res.status === 'success') setServicios(res.services); })
.catch(() => {});
odooApi.getServiceCategories()
.then((res) => { if (res.status === 'success') setCategorias(res.categories); })
.catch(() => {});
}, []);
const loadPlanes = useCallback(async () => {
try {
setCargandoPlanes(true);
const res = await odooApi.getMembresiaPlanes({ includeInactive: mostrarBaja });
if (res.status === 'success') setPlanes(res.planes);
} catch {
toast.error('Error al cargar los planes');
} finally {
setCargandoPlanes(false);
}
}, [mostrarBaja]);
useEffect(() => { loadPlanes(); }, [loadPlanes]);
const set = <K extends keyof MembresiasSettings>(k: K, v: MembresiasSettings[K]) =>
setForm((prev) => ({ ...prev, [k]: v }));
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; }
setSaving(true);
try {
const res = await odooApi.putClinicSettings({
membresias: { ...form, dias_aviso_renovacion: dias },
motivo: 'Configuración de membresías',
});
if (res.status === 'success') {
onSaved(res.settings);
toast.success('Configuración de membresías guardada');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la configuración');
} finally {
setSaving(false);
}
};
const abrirNuevo = () => { setPlanForm(emptyForm); setFormOpen(true); };
const abrirEditar = (p: MembresiaPlan) => {
setPlanForm({
id: p.id, name: p.name, periodicidad: p.periodicidad, precio: String(p.precio),
moneda: p.moneda, sesiones_incluidas: String(p.sesiones_incluidas),
descuento_porcentaje: String(p.descuento_porcentaje),
cargo_inscripcion: String(p.cargo_inscripcion), meses_minimos: String(p.meses_minimos),
permitir_congelamiento: p.permitir_congelamiento, activo: p.activo,
servicio_id: p.servicio_id ? String(p.servicio_id) : '',
servicios_incluidos: p.servicios_incluidos.map((s) => s.id),
categorias_incluidas: p.categorias_incluidas.map((c) => c.id),
descripcion: p.descripcion || '', notas: p.notas || '',
});
setFormOpen(true);
};
const guardarPlan = async () => {
if (!planForm.name.trim()) { toast.error('El nombre del plan es obligatorio'); return; }
const payload = {
name: planForm.name.trim(),
periodicidad: planForm.periodicidad,
precio: parseFloat(planForm.precio) || 0,
moneda: planForm.moneda,
sesiones_incluidas: parseInt(planForm.sesiones_incluidas, 10) || 0,
descuento_porcentaje: parseFloat(planForm.descuento_porcentaje) || 0,
cargo_inscripcion: parseFloat(planForm.cargo_inscripcion) || 0,
meses_minimos: parseInt(planForm.meses_minimos, 10) || 0,
permitir_congelamiento: planForm.permitir_congelamiento,
activo: planForm.activo,
servicio_id: planForm.servicio_id ? parseInt(planForm.servicio_id, 10) : null,
servicios_incluidos: planForm.servicios_incluidos,
categorias_incluidas: planForm.categorias_incluidas,
descripcion: planForm.descripcion.trim(),
notas: planForm.notas.trim(),
};
try {
setGuardandoPlan(true);
const res = planForm.id
? await odooApi.updateMembresiaPlan(planForm.id, payload)
: await odooApi.createMembresiaPlan(payload);
if (res.status !== 'success') { toast.error(res.message || 'No se pudo guardar el plan'); return; }
toast.success(planForm.id ? 'Plan actualizado' : 'Plan creado');
setFormOpen(false);
loadPlanes();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar el plan');
} finally {
setGuardandoPlan(false);
}
};
const cambiarActivo = async (p: MembresiaPlan) => {
const texto = p.activo
? `¿Desactivar "${p.name}"? Dejará de ofrecerse para nuevas membresías.`
: `¿Reactivar "${p.name}"?`;
if (!window.confirm(texto)) return;
try {
const res = await odooApi.updateMembresiaPlan(p.id, { activo: !p.activo });
if (res.status !== 'success') { toast.error(res.message || 'No se pudo cambiar'); return; }
loadPlanes();
} catch { toast.error('Error al cambiar el estado'); }
};
const borrarPlan = async (p: MembresiaPlan) => {
if (!window.confirm(`¿Borrar el plan "${p.name}"? Solo se puede si no tiene membresías.`)) return;
try {
const res = await odooApi.deleteMembresiaPlan(p.id);
if (res.status !== 'success') { toast.error(res.message || 'No se pudo borrar'); return; }
toast.success('Plan eliminado');
loadPlanes();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al borrar el plan');
}
};
const toggleId = (list: number[], id: number) =>
list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
return (
<div className="space-y-8">
<div className="flex items-start gap-3">
<Repeat size={20} className="mt-0.5 shrink-0 text-theme-accent" />
<div>
<h3 className="font-heading text-xl text-theme-heading">Membresías y suscripciones</h3>
<p className="text-sm text-theme-muted">
Planes recurrentes con vigencia, sesiones incluidas por periodo, cobro recurrente,
congelamiento y renovación. Mientras esté apagado, no aparece en el menú, el punto de
venta no cambia y no se genera ningún cargo ni aviso.
</p>
</div>
</div>
<div className="space-y-5 rounded-2xl border border-theme-border p-4">
<Toggle
label="Activar membresías"
checked={form.activo}
onChange={(v) => set('activo', v)}
ayuda="Enciende la sección Membresías, el cobro recurrente y los avisos de renovación."
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-theme-muted mb-1.5">Días de aviso de renovación</label>
<Input value={avisos} onChange={(e) => setAvisos(e.target.value)} placeholder="7, 1" />
<p className="text-[11px] text-theme-muted mt-1">Días antes del cobro en que se avisa (separados por coma).</p>
</div>
<div>
<label className="block text-xs font-medium text-theme-muted mb-1.5">Máximo de días congelados por año</label>
<Input type="number" min={0} value={form.dias_max_congelamiento_anio}
onChange={(e) => set('dias_max_congelamiento_anio', parseInt(e.target.value, 10) || 0)} />
</div>
<div>
<label className="block text-xs font-medium text-theme-muted mb-1.5">Días de gracia antes de vencer</label>
<Input type="number" min={0} value={form.dias_gracia}
onChange={(e) => set('dias_gracia', parseInt(e.target.value, 10) || 0)} />
</div>
<div>
<label className="block text-xs font-medium text-theme-muted mb-1.5">Descuento por defecto en no incluidos (%)</label>
<Input type="number" min={0} max={100} value={form.descuento_porcentaje_default}
onChange={(e) => set('descuento_porcentaje_default', parseFloat(e.target.value) || 0)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Toggle label="Permitir congelamiento" checked={form.permitir_congelamiento}
onChange={(v) => set('permitir_congelamiento', v)} />
<Toggle label="Renovación automática por defecto" checked={form.renovacion_automatica_default}
onChange={(v) => set('renovacion_automatica_default', v)} />
<Toggle label="Generar cargo al renovar" checked={form.generar_cargo_al_renovar}
onChange={(v) => set('generar_cargo_al_renovar', v)}
ayuda="El cron crea el adeudo del nuevo periodo (no cobra)." />
<Toggle label="Permitir pausa por falta de pago" checked={form.permitir_pausa_pago}
onChange={(v) => set('permitir_pausa_pago', v)} />
</div>
<div className="flex justify-end">
<Button onClick={guardarConfig} disabled={saving}>
<Save size={16} className="mr-2" /> {saving ? 'Guardando…' : 'Guardar configuración'}
</Button>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<h4 className="font-medium text-theme-heading">Planes</h4>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm text-theme-muted">
<input type="checkbox" checked={mostrarBaja} onChange={(e) => setMostrarBaja(e.target.checked)}
className="rounded border-theme-border-strong" />
Mostrar inactivos
</label>
<Button onClick={abrirNuevo}><Plus size={16} className="mr-2" /> Nuevo plan</Button>
</div>
</div>
{cargandoPlanes ? (
<p className="text-sm text-theme-muted">Cargando planes…</p>
) : planes.length === 0 ? (
<p className="text-sm text-theme-muted">Todavía no hay planes de membresía.</p>
) : (
<div className="overflow-x-auto rounded-2xl border border-theme-border">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Plan</th>
<th className="text-left px-4 py-3">Periodicidad</th>
<th className="text-right px-4 py-3">Precio</th>
<th className="text-right px-4 py-3">Sesiones</th>
<th className="text-right px-4 py-3">Inscripción</th>
<th className="text-right px-4 py-3">Membresías</th>
<th className="text-left px-4 py-3">Estado</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{planes.map((p) => (
<tr key={p.id} className={!p.activo ? 'opacity-60' : ''}>
<td className="px-4 py-3">
<p className="font-medium text-theme-heading">{p.name}</p>
{p.servicio && <p className="text-[11px] text-theme-muted">Cobra con: {p.servicio}</p>}
</td>
<td className="px-4 py-3 text-theme-muted capitalize">{p.periodicidad}</td>
<td className="px-4 py-3 text-right tabular-nums">{p.precio.toLocaleString('es-MX', { style: 'currency', currency: p.moneda, maximumFractionDigits: 2 })}</td>
<td className="px-4 py-3 text-right tabular-nums">{p.sesiones_incluidas === 0 ? 'Ilimitadas' : p.sesiones_incluidas}</td>
<td className="px-4 py-3 text-right tabular-nums">{p.cargo_inscripcion ? p.cargo_inscripcion.toLocaleString('es-MX', { style: 'currency', currency: p.moneda }) : '—'}</td>
<td className="px-4 py-3 text-right tabular-nums">{p.membresias_count}</td>
<td className="px-4 py-3">
<button type="button" onClick={() => cambiarActivo(p)}
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[11px] font-medium ${p.activo ? 'border-emerald-300 bg-emerald-50 text-emerald-800' : 'border-rose-300 bg-rose-50 text-rose-700'}`}>
{p.activo ? 'Activo' : 'Inactivo'}
</button>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1.5">
<Button variant="outline" size="sm" onClick={() => abrirEditar(p)} title="Editar"><Pencil size={13} /></Button>
<Button variant="outline" size="sm" onClick={() => borrarPlan(p)} title="Borrar"><Trash2 size={13} /></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Modal isOpen={formOpen} onClose={() => setFormOpen(false)}
title={planForm.id ? 'Editar plan de membresía' : 'Nuevo plan de membresía'}>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Nombre *</label>
<Input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })}
placeholder="Ej. Plan mensual 4 masajes" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Periodicidad</label>
<Select options={PERIODICIDADES} value={planForm.periodicidad}
onChange={(e) => setPlanForm({ ...planForm, periodicidad: e.target.value as PeriodicidadMembresia })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Moneda</label>
<Select options={[{ value: 'MXN', label: 'MXN' }, { value: 'USD', label: 'USD' }]}
value={planForm.moneda} onChange={(e) => setPlanForm({ ...planForm, moneda: e.target.value as 'MXN' | 'USD' })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Precio por periodo *</label>
<Input type="number" min={0} value={planForm.precio}
onChange={(e) => setPlanForm({ ...planForm, precio: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Sesiones incluidas (0 = ilimitadas)</label>
<Input type="number" min={0} value={planForm.sesiones_incluidas}
onChange={(e) => setPlanForm({ ...planForm, sesiones_incluidas: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Cargo de inscripción</label>
<Input type="number" min={0} value={planForm.cargo_inscripcion}
onChange={(e) => setPlanForm({ ...planForm, cargo_inscripcion: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Descuento en no incluidos (%)</label>
<Input type="number" min={0} max={100} value={planForm.descuento_porcentaje}
onChange={(e) => setPlanForm({ ...planForm, descuento_porcentaje: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Meses mínimos</label>
<Input type="number" min={0} value={planForm.meses_minimos}
onChange={(e) => setPlanForm({ ...planForm, meses_minimos: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Servicio de cobro</label>
<Select
options={[{ value: '', label: 'Primer servicio incluido' },
...servicios.map((s) => ({ value: String(s.id), label: s.name }))]}
value={planForm.servicio_id}
onChange={(e) => setPlanForm({ ...planForm, servicio_id: e.target.value })} />
<p className="text-[11px] text-theme-muted mt-1">Servicio con el que se registra la venta en caja.</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Servicios incluidos</label>
<div className="max-h-40 overflow-y-auto rounded-xl border border-theme-border p-2 space-y-1">
{servicios.length === 0 && <p className="text-xs text-theme-muted px-1">No hay servicios en el catálogo.</p>}
{servicios.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-sm text-theme-heading px-1">
<input type="checkbox" checked={planForm.servicios_incluidos.includes(s.id)}
onChange={() => setPlanForm({ ...planForm, servicios_incluidos: toggleId(planForm.servicios_incluidos, s.id) })}
className="rounded border-theme-border-strong" />
{s.name}
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Categorías incluidas</label>
<div className="max-h-32 overflow-y-auto rounded-xl border border-theme-border p-2 space-y-1">
{categorias.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-sm text-theme-heading px-1">
<input type="checkbox" checked={planForm.categorias_incluidas.includes(c.id)}
onChange={() => setPlanForm({ ...planForm, categorias_incluidas: toggleId(planForm.categorias_incluidas, c.id) })}
className="rounded border-theme-border-strong" />
{c.name}
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Toggle label="Permite congelamiento" checked={planForm.permitir_congelamiento}
onChange={(v) => setPlanForm({ ...planForm, permitir_congelamiento: v })} />
<Toggle label="Activo" checked={planForm.activo}
onChange={(v) => setPlanForm({ ...planForm, activo: v })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Descripción</label>
<TextArea rows={2} value={planForm.descripcion}
onChange={(e) => setPlanForm({ ...planForm, descripcion: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Notas</label>
<TextArea rows={2} value={planForm.notas}
onChange={(e) => setPlanForm({ ...planForm, notas: e.target.value })} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancelar</Button>
<Button onClick={guardarPlan} disabled={guardandoPlan}>
{guardandoPlan ? 'Guardando…' : 'Guardar plan'}
</Button>
</div>
</div>
</Modal>
</div>
);
};
export default MembresiasPanel;

View File

@@ -9,6 +9,7 @@ import { odooApi, type Patient, type Visita, type EstadoCuenta, type PatientWall
import { Badge, Button, Skeleton } from './ui';
import RecargarSaldoModal from './RecargarSaldoModal';
import ReciboPrint from './ReciboPrint';
import MembresiasPaciente from './MembresiasPaciente';
import { waLink } from '../lib/whatsapp';
import { badgeForAppointmentState, badgeForSaleState } from './ui';
@@ -317,6 +318,8 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa
{tab === 'resumen' ? (
<>
{/* Membresías activas (solo si la clínica las tiene encendidas) */}
<MembresiasPaciente partnerId={patient.id} />
{/* Contacto */}
<div className="grid grid-cols-2 gap-2">
<div className="p-3 bg-theme-bg rounded-xl">

View File

@@ -38,6 +38,7 @@ import {
Layers,
Clock,
Download,
Repeat,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -46,6 +47,7 @@ import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell';
import { useAuth, ROLE_LABELS } from '../lib/auth';
import { useGlosario, traducirMenu } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
@@ -118,12 +120,25 @@ const menuGroups = [
},
];
// La sección Membresías solo existe cuando la clínica enciende
// `membresias.activo`. Con el módulo apagado (default) el menú queda idéntico
// al de siempre.
const MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario();
const menuGroupsT = useMemo(() => traducirMenu(menuGroups, term), [term]);
const { membresias: membresiasCfg } = useClinicSettings();
const menuGroupsT = useMemo(() => {
const base = membresiasCfg.activo
? menuGroups.map((g) => (g.label === 'Clínica'
? { ...g, items: [...g.items, MEMBRESIA_ITEM] }
: g))
: menuGroups;
return traducirMenu(base, term);
}, [term, membresiasCfg.activo]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas

View File

@@ -0,0 +1,171 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Repeat, RefreshCw } from 'lucide-react';
import { Card, Button, EmptyState, Skeleton, Badge, toast } from '../ui';
import { odooApi, type MembresiasReporte } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
const fmtMoney = (value: number, currency = 'MXN') =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value || 0);
// Pestaña "Membresías" del módulo de Reportes: altas, cancelaciones, ingresos
// recurrentes por mes, próximas renovaciones y sesiones consumidas.
const MembresiasReport: FC = () => {
const { start, end } = useReportRange('membresias');
const [data, setData] = useState<MembresiasReporte | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getMembresiasReporte({ start, end });
if (res.status === 'success') setData(res);
else toast.error(res.message || 'No se pudo cargar el reporte');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cargar el reporte de membresías');
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
if (loading) {
return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
}
if (!data) {
return (
<Card><Card.Body>
<EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte de membresías." icon={<Repeat size={28} />} />
</Card.Body></Card>
);
}
const totalConsumidas = data.sesiones_consumidas || 0;
const incluidas = data.sesiones_incluidas_activas || 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-theme-muted">
Periodo {data.start} a {data.end}
</p>
<Button variant="outline" size="sm" onClick={load}>
<RefreshCw size={14} className="mr-1.5" /> Actualizar
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card><Card.Body>
<p className="text-xs text-theme-muted">Altas del periodo</p>
<p className="text-2xl font-heading text-theme-heading">{data.altas}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Cancelaciones</p>
<p className="text-2xl font-heading text-theme-heading">{data.cancelaciones}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Membresías activas</p>
<p className="text-2xl font-heading text-theme-heading">{data.membresias_activas}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Ingresos por membresía</p>
<p className="text-2xl font-heading text-theme-heading">{fmtMoney(data.total_ingresos)}</p>
<p className="text-[11px] text-theme-muted">de ellos {fmtMoney(data.total_recurrente)} recurrentes</p>
</Card.Body></Card>
</div>
<Card>
<Card.Header><p className="font-medium text-theme-heading">Ingresos por mes</p></Card.Header>
<Card.Body noPadding>
{data.recurrentes_por_mes.length === 0 ? (
<div className="p-6"><p className="text-sm text-theme-muted">Sin cobros de membresía en el periodo.</p></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Mes</th>
<th className="text-right px-4 py-3">Ingreso recurrente</th>
<th className="text-right px-4 py-3">Total cobrado</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.ingresos_por_mes.map((row) => {
const rec = data.recurrentes_por_mes.find((r) => r.mes === row.mes)?.total ?? 0;
return (
<tr key={row.mes}>
<td className="px-4 py-3 text-theme-heading">{row.mes}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(rec)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(row.total)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<Card.Header><p className="font-medium text-theme-heading">Próximas renovaciones (60 días)</p></Card.Header>
<Card.Body noPadding>
{data.proximas_renovaciones.length === 0 ? (
<div className="p-6"><p className="text-sm text-theme-muted">No hay renovaciones próximas.</p></div>
) : (
<ul className="divide-y divide-theme-border">
{data.proximas_renovaciones.map((r) => (
<li key={r.id} className="px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm text-theme-heading truncate">{r.patient}</p>
<p className="text-[11px] text-theme-muted">{r.folio} · {r.plan}</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm text-theme-heading">{r.proximo_cobro}</p>
<p className="text-[11px] text-theme-muted">{fmtMoney(r.precio, r.moneda)}</p>
</div>
</li>
))}
</ul>
)}
</Card.Body>
</Card>
<Card>
<Card.Header><p className="font-medium text-theme-heading">Sesiones</p></Card.Header>
<Card.Body>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-theme-muted">Consumidas en el periodo</span>
<span className="text-theme-heading font-semibold tabular-nums">{totalConsumidas}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-theme-muted">Incluidas en membresías activas</span>
<span className="text-theme-heading font-semibold tabular-nums">{incluidas}</span>
</div>
{incluidas > 0 && (
<div>
<div className="h-2 rounded-full bg-theme-bg overflow-hidden">
<div
className="h-full bg-theme-accent"
style={{ width: `${Math.min(100, Math.round((totalConsumidas / incluidas) * 100))}%` }}
/>
</div>
<p className="text-[11px] text-theme-muted mt-1.5">
Uso estimado del {Math.min(100, Math.round((totalConsumidas / incluidas) * 100))}% de las sesiones incluidas.
</p>
</div>
)}
<Badge variant="info">Nota: comparación por periodo; estimación de uso.</Badge>
</div>
</Card.Body>
</Card>
</div>
</div>
);
};
export default MembresiasReport;

View File

@@ -37,6 +37,9 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/medicos': ['admin', 'recepcion', 'gerencia'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia', 'cosmetologia'],
// Membresías (apagado por defecto: la sección solo se pinta con
// membresias.activo=true). Se lista aquí para que canSee la reconozca.
'/membresias': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario
'/ventas': ['admin', 'recepcion', 'gerencia'],
'/tickets': ['admin', 'gerencia'],
@@ -49,6 +52,7 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=paquetes': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=membresias': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_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.
@@ -10,6 +10,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
...(settings || {}),
impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) },
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
});
let actual: ClinicSettings = normalizar(null);

View File

@@ -29,6 +29,7 @@ export const MENU_ITEMS: MenuItem[] = [
{ label: 'Adeudos', href: '/reportes?tab=adeudos' },
{ label: 'Horas agenda', href: '/reportes?tab=horas-agenda' },
{ label: 'Paquetes', href: '/reportes?tab=paquetes', termKey: 'package_plural' },
{ label: 'Membresías', href: '/membresias' },
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },
{ label: 'Concentrado', href: '/reportes?tab=concentrado' },
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },

View File

@@ -17,6 +17,8 @@ export const PERM_KEYS: string[] = [
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
'membresias.ver', 'membresias.crear', 'membresias.cobrar', 'membresias.editar',
'membresias.cancelar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
];
@@ -41,6 +43,7 @@ const READ_PERM_KEYS = new Set([
'whatsapp.ver',
'notificaciones.ver',
'formularios.ver',
'membresias.ver',
]);
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
@@ -62,6 +65,9 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
'formularios.ver': ALL, 'formularios.crear': FORM_WRITE, 'formularios.editar': AG,
'formularios.enviar': FORM_WRITE,
'membresias.ver': ALL, 'membresias.crear': ['admin', 'recepcion', 'gerencia'],
'membresias.cobrar': ['admin', 'recepcion', 'gerencia'], 'membresias.editar': AG,
'membresias.cancelar': AG,
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
};

View File

@@ -17,6 +17,7 @@ import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
import NotificacionesPanel from '../components/NotificacionesPanel';
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
import FormulariosPanel from '../components/FormulariosPanel';
import MembresiasPanel from '../components/MembresiasPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth';
@@ -30,6 +31,7 @@ const TABS = [
{ key: 'formularios', label: 'Formularios' },
{ key: 'sucursales', label: 'Sucursales' },
{ key: 'categorias', label: 'Categorías' },
{ key: 'membresias', label: 'Membresías' },
{ key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'roles', label: 'Roles' },
@@ -40,7 +42,7 @@ const TABS = [
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'membresias', 'terminologia', 'usuarios', 'roles', 'tickets'];
const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -64,6 +66,7 @@ const Configuracion: FC = () => {
notificaciones: 'notificaciones.editar',
notif_enviadas: 'notificaciones.ver',
formularios: 'formularios.ver',
membresias: 'membresias.editar',
};
const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
@@ -379,6 +382,12 @@ const Configuracion: FC = () => {
<CategoriasPanel />
</Card.Body>
</Card>
) : tab === 'membresias' ? (
<Card>
<Card.Body>
<MembresiasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'terminologia' ? (
<Card>
<Card.Body>

View File

@@ -0,0 +1,611 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Repeat, Plus, Search, RefreshCw, ChevronRight, CheckCircle2, AlertTriangle,
Snowflake, XCircle, Banknote,
} from 'lucide-react';
import Layout from '../components/Layout';
import {
Card, Button, Input, Select, Modal, PageHeader, EmptyState, Skeleton, toast,
} from '../components/ui';
import { odooApi } from '../services/odoo';
import type { Membresia, MembresiaDetalleResponse, MembresiaPlan, Patient } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario';
const fmtMoney = (n: number, currency = 'MXN') =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency, maximumFractionDigits: 2 });
const ESTADO_CLS: Record<string, string> = {
activa: 'bg-emerald-100 text-emerald-700 border-emerald-200',
congelada: 'bg-sky-100 text-sky-700 border-sky-200',
vencida: 'bg-amber-100 text-amber-700 border-amber-200',
pausada: 'bg-theme-bg text-theme-muted border-theme-border',
cancelada: 'bg-rose-100 text-rose-700 border-rose-200',
};
const METODOS = [
{ value: 'cash', label: 'Efectivo' },
{ value: 'card', label: 'Tarjeta' },
{ value: 'transfer', label: 'Transferencia' },
{ value: 'stripe', label: 'Stripe' },
{ value: 'mercadopago', label: 'MercadoPago' },
{ value: 'saldo_favor', label: 'Saldo a favor' },
];
const CHIPS = [
{ key: '', label: 'Todas', icon: undefined as typeof CheckCircle2 | undefined },
{ key: 'activa', label: 'Activas', icon: CheckCircle2 },
{ key: 'por_vencer', label: 'Por vencer', icon: AlertTriangle },
{ key: 'vencida', label: 'Vencidas', icon: XCircle },
{ key: 'congelada', label: 'Congeladas', icon: Snowflake },
];
const Membresias: FC = () => {
const { term } = useGlosario();
const { can } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [membresias, setMembresias] = useState<Membresia[]>([]);
const [resumen, setResumen] = useState({ activas: 0, por_vencer: 0, vencidas: 0, mrr_estimado: 0 });
const [planes, setPlanes] = useState<MembresiaPlan[]>([]);
const [cargando, setCargando] = useState(true);
const [filtro, setFiltro] = useState('');
const [busqueda, setBusqueda] = useState('');
const [partnerFiltro, setPartnerFiltro] = useState('');
// Detalle
const [detalle, setDetalle] = useState<MembresiaDetalleResponse | null>(null);
const [cargandoDetalle, setCargandoDetalle] = useState(false);
// Acciones
const [cobroTarget, setCobroTarget] = useState<Membresia | null>(null);
const [cobroForm, setCobroForm] = useState({ metodo: 'cash', monto: '', moneda: 'MXN', cobrar_inscripcion: false });
const [congelarTarget, setCongelarTarget] = useState<Membresia | null>(null);
const [congelarForm, setCongelarForm] = useState({ desde: '', hasta: '', motivo: '' });
const [accionando, setAccionando] = useState(false);
// Alta
const [nuevaOpen, setNuevaOpen] = useState(false);
const [pacienteQuery, setPacienteQuery] = useState('');
const [pacienteResults, setPacienteResults] = useState<Patient[]>([]);
const [paciente, setPaciente] = useState<Patient | null>(null);
const [nueva, setNueva] = useState({ plan_id: '', fecha_inicio: '', renovacion_automatica: false, descuento: '' });
const load = useCallback(async () => {
try {
setCargando(true);
const res = await odooApi.getMembresias({
estado: filtro && filtro !== 'por_vencer' ? filtro : undefined,
por_vencer: filtro === 'por_vencer' ? true : undefined,
q: busqueda.trim() || undefined,
partner_id: partnerFiltro ? parseInt(partnerFiltro, 10) : undefined,
});
if (res.status === 'success') {
setMembresias(res.membresias);
setResumen(res.resumen || { activas: 0, por_vencer: 0, vencidas: 0, mrr_estimado: 0 });
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cargar las membresías');
} finally {
setCargando(false);
}
}, [filtro, busqueda, partnerFiltro]);
useEffect(() => {
odooApi.getMembresiaPlanes()
.then((res) => { if (res.status === 'success') setPlanes(res.planes); })
.catch(() => {});
}, []);
// Filtro por paciente desde el expediente: /membresias?partner_id=X
useEffect(() => {
const pid = searchParams.get('partner_id');
if (pid) setPartnerFiltro(pid);
}, [searchParams]);
useEffect(() => {
const t = setTimeout(load, 300);
return () => clearTimeout(t);
}, [load]);
useEffect(() => {
if (paciente) return;
if (pacienteQuery.trim().length < 2) { setPacienteResults([]); return; }
let alive = true;
const t = setTimeout(() => {
odooApi.getPatients({ search: pacienteQuery.trim(), page_size: 8 })
.then((res) => { if (alive && res.status === 'success') setPacienteResults(res.patients); })
.catch(() => {});
}, 250);
return () => { alive = false; clearTimeout(t); };
}, [pacienteQuery, paciente]);
const abrirDetalle = async (m: Membresia) => {
setCargandoDetalle(true);
setDetalle(null);
try {
const res = await odooApi.getMembresia(m.id);
if (res.status === 'success') setDetalle(res);
else toast.error(res.message || 'No se pudo abrir la membresía');
} catch {
toast.error('Error al abrir la membresía');
} finally {
setCargandoDetalle(false);
}
};
const refrescar = () => { load(); if (detalle) abrirDetalle(detalle.membresia); };
const abrirCobro = (m: Membresia) => {
setCobroForm({ metodo: 'cash', monto: String(m.precio), moneda: m.moneda || 'MXN', cobrar_inscripcion: false });
setCobroTarget(m);
};
const cobrar = async () => {
if (!cobroTarget) return;
try {
setAccionando(true);
const res = await odooApi.cobrarMembresia(cobroTarget.id, {
metodo: cobroForm.metodo,
monto: cobroForm.monto ? parseFloat(cobroForm.monto) : undefined,
moneda: cobroForm.moneda,
cobrar_inscripcion: cobroForm.cobrar_inscripcion,
});
if (res.status !== 'success') { toast.error(res.message || 'No se pudo cobrar'); return; }
toast.success(`Cobrado: venta ${res.venta.name} · pago ${res.pago.name}`);
setCobroTarget(null);
setDetalle(null);
refrescar();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cobrar');
} finally {
setAccionando(false);
}
};
const renovar = async (m: Membresia) => {
if (!window.confirm(`¿Renovar "${m.plan}" de ${m.patient}? Se avanza el periodo.`)) return;
try {
const res = await odooApi.renovarMembresia(m.id);
if (res.status !== 'success') { toast.error(res.message || 'No se pudo renovar'); return; }
toast.success('Membresía renovada');
setDetalle(null);
refrescar();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al renovar');
}
};
const descongelar = async (m: Membresia) => {
try {
const res = await odooApi.descongelarMembresia(m.id);
if (res.status !== 'success') { toast.error(res.message || 'No se pudo descongelar'); return; }
toast.success('Membresía reactivada');
setDetalle(null);
refrescar();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al descongelar');
}
};
const congelar = async () => {
if (!congelarTarget) return;
if (!congelarForm.desde || !congelarForm.hasta) { toast.error('Indica el rango de congelamiento'); return; }
try {
setAccionando(true);
const res = await odooApi.congelarMembresia(congelarTarget.id, congelarForm);
if (res.status !== 'success') { toast.error(res.message || 'No se pudo congelar'); return; }
toast.success('Membresía congelada');
setCongelarTarget(null);
setDetalle(null);
refrescar();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al congelar');
} finally {
setAccionando(false);
}
};
const cancelar = async (m: Membresia) => {
const motivo = window.prompt(`¿Cancelar la membresía de ${m.patient}?\n\nMotivo (obligatorio):`);
if (!motivo || !motivo.trim()) { if (motivo !== null) toast.error('Indica el motivo'); return; }
try {
const res = await odooApi.cancelarMembresia(m.id, motivo.trim());
if (res.status !== 'success') { toast.error(res.message || 'No se pudo cancelar'); return; }
toast.success('Membresía cancelada');
setDetalle(null);
refrescar();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cancelar');
}
};
const abrirNueva = () => {
setPaciente(null);
setPacienteQuery('');
setPacienteResults([]);
setNueva({ plan_id: '', fecha_inicio: new Date().toISOString().slice(0, 10), renovacion_automatica: false, descuento: '' });
setNuevaOpen(true);
};
const crear = async () => {
if (!paciente) { toast.error('Selecciona el paciente'); return; }
if (!nueva.plan_id) { toast.error('Selecciona el plan'); return; }
try {
setAccionando(true);
const res = await odooApi.crearMembresia({
partner_id: paciente.id,
plan_id: parseInt(nueva.plan_id, 10),
fecha_inicio: nueva.fecha_inicio || undefined,
renovacion_automatica: nueva.renovacion_automatica,
...(nueva.descuento !== '' ? { descuento_porcentaje: parseFloat(nueva.descuento) } : {}),
});
if (res.status !== 'success') { toast.error(res.message || 'No se pudo crear'); return; }
toast.success('Membresía creada');
setNuevaOpen(false);
load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al crear la membresía');
} finally {
setAccionando(false);
}
};
return (
<Layout title="Membresías" subtitle="Suscripciones y cobro recurrente">
<PageHeader title="Membresías" subtitle="Planes recurrentes, sesiones incluidas, cobros y vencimientos">
<Button variant="outline" onClick={refrescar}>
<RefreshCw size={16} className="mr-2" /> Actualizar
</Button>
{can('membresias.crear') && (
<Button onClick={abrirNueva}><Plus size={16} className="mr-2" /> Nueva membresía</Button>
)}
</PageHeader>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<Card><Card.Body>
<p className="text-xs text-theme-muted">Activas</p>
<p className="text-2xl font-heading text-theme-heading">{resumen.activas}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Por vencer</p>
<p className="text-2xl font-heading text-amber-700">{resumen.por_vencer}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Vencidas</p>
<p className="text-2xl font-heading text-rose-700">{resumen.vencidas}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">MRR estimado</p>
<p className="text-2xl font-heading text-theme-heading">{fmtMoney(resumen.mrr_estimado)}</p>
</Card.Body></Card>
</div>
<Card>
<Card.Header>
<div className="flex flex-wrap items-center gap-1.5">
{CHIPS.map((c) => (
<button key={c.key} type="button" onClick={() => setFiltro(c.key)}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition flex items-center gap-1 ${
filtro === c.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'}`}>
{c.icon && <c.icon size={12} />}
{c.label}
</button>
))}
</div>
<div className="flex items-center gap-2 w-full max-w-md">
<div className="relative flex-1">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input value={busqueda} onChange={(e) => setBusqueda(e.target.value)}
placeholder={`Buscar por ${term('patient_singular').toLowerCase()}, folio o plan`} className="pl-9" />
</div>
{partnerFiltro && (
<Button variant="outline" size="sm" onClick={() => { setPartnerFiltro(''); setSearchParams({}); }}>
Quitar filtro
</Button>
)}
</div>
</Card.Header>
<Card.Body noPadding>
{cargando ? (
<div className="p-6"><Skeleton count={6} /></div>
) : membresias.length === 0 ? (
<EmptyState title="Sin membresías" subtitle="Da de alta la primera para empezar a cobrar de forma recurrente."
icon={<Repeat size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">{term('patient_singular')}</th>
<th className="text-left px-4 py-3">Plan</th>
<th className="text-left px-4 py-3">Periodo</th>
<th className="text-left px-4 py-3">Próximo cobro</th>
<th className="text-left px-4 py-3">Sesiones</th>
<th className="text-right px-4 py-3">Adeudo</th>
<th className="text-left px-4 py-3">Estado</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{membresias.map((m) => (
<tr key={m.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => abrirDetalle(m)}>
<td className="px-4 py-3">
<p className="font-medium text-theme-heading">{m.patient}</p>
<p className="text-[11px] text-theme-muted">{m.folio}{m.phone ? ` · ${m.phone}` : ''}</p>
</td>
<td className="px-4 py-3">
<p className="text-theme-heading">{m.plan}</p>
<p className="text-[11px] text-theme-muted capitalize">{m.precio ? fmtMoney(m.precio, m.moneda) + ' / ' + m.periodicidad : m.periodicidad}</p>
</td>
<td className="px-4 py-3 text-theme-muted whitespace-nowrap">
{m.periodo_actual_inicio || '—'}<br /><span className="text-[11px]">a {m.periodo_actual_fin || '—'}</span>
</td>
<td className="px-4 py-3 whitespace-nowrap">
{m.proximo_cobro ? (
<span className={m.dias_para_cobro >= 0 && m.dias_para_cobro <= 7 ? 'text-amber-700 font-medium' : 'text-theme-muted'}>
{m.proximo_cobro}
<span className="block text-[10px]">{m.dias_para_cobro >= 0 ? `en ${m.dias_para_cobro} días` : `vencido ${-m.dias_para_cobro} d`}</span>
</span>
) : <span className="text-theme-muted">—</span>}
</td>
<td className="px-4 py-3 text-theme-muted whitespace-nowrap">
{m.ilimitadas ? 'Ilimitadas' : `${m.sesiones_usadas_periodo}/${m.sesiones_incluidas_periodo}`}
</td>
<td className={`px-4 py-3 text-right tabular-nums ${m.adeudo > 0 ? 'text-amber-700 font-medium' : 'text-theme-muted'}`}>
{m.adeudo > 0 ? fmtMoney(m.adeudo, m.moneda) : '—'}
</td>
<td className="px-4 py-3">
<span className={`inline-block text-[11px] font-medium px-2 py-0.5 rounded-full border ${ESTADO_CLS[m.estado] || ESTADO_CLS.pausada}`}>
{m.estado_label}
</span>
</td>
<td className="px-4 py-3 text-right">
<ChevronRight size={16} className="inline text-theme-muted" />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
{/* Detalle */}
<Modal isOpen={!!detalle || cargandoDetalle} onClose={() => setDetalle(null)}
title={detalle ? `${detalle.membresia.folio} · ${detalle.membresia.patient}` : 'Cargando…'}>
{detalle && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div><p className="text-theme-muted">Plan</p><p className="font-medium text-theme-heading">{detalle.membresia.plan}</p></div>
<div><p className="text-theme-muted">Estado</p><p className="text-theme-heading">{detalle.membresia.estado_label}</p></div>
<div><p className="text-theme-muted">Periodo actual</p><p className="text-theme-heading">{detalle.membresia.periodo_actual_inicio} a {detalle.membresia.periodo_actual_fin}</p></div>
<div><p className="text-theme-muted">Próximo cobro</p><p className="text-theme-heading">{detalle.membresia.proximo_cobro || '—'}</p></div>
<div><p className="text-theme-muted">Sesiones</p><p className="text-theme-heading">{detalle.saldos.ilimitadas ? 'Ilimitadas' : `${detalle.membresia.sesiones_usadas_periodo}/${detalle.membresia.sesiones_incluidas_periodo} · quedan ${detalle.saldos.sesiones_restantes}`}</p></div>
<div><p className="text-theme-muted">Adeudo</p><p className={detalle.saldos.adeudo > 0 ? 'text-amber-700 font-medium' : 'text-theme-heading'}>{fmtMoney(detalle.saldos.adeudo, detalle.membresia.moneda)}</p></div>
</div>
<div className="flex flex-wrap gap-2">
{can('membresias.cobrar') && detalle.membresia.estado !== 'cancelada' && (
<Button size="sm" onClick={() => abrirCobro(detalle.membresia)}><Banknote size={14} className="mr-1" /> Cobrar periodo</Button>
)}
{can('membresias.cobrar') && detalle.membresia.estado !== 'cancelada' && (
<Button variant="outline" size="sm" onClick={() => renovar(detalle.membresia)}>Renovar</Button>
)}
{can('membresias.cancelar') && detalle.membresia.estado === 'activa' && (
<Button variant="outline" size="sm" onClick={() => { setCongelarForm({ desde: '', hasta: '', motivo: '' }); setCongelarTarget(detalle.membresia); }}>
<Snowflake size={14} className="mr-1" /> Congelar
</Button>
)}
{can('membresias.cancelar') && detalle.membresia.estado === 'congelada' && (
<Button variant="outline" size="sm" onClick={() => descongelar(detalle.membresia)}>Descongelar</Button>
)}
{can('membresias.cancelar') && detalle.membresia.estado !== 'cancelada' && (
<Button variant="outline" size="sm" className="!text-rose-600 !border-rose-200" onClick={() => cancelar(detalle.membresia)}>
<XCircle size={14} className="mr-1" /> Cancelar
</Button>
)}
</div>
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Periodos y pagos</p>
{detalle.periodos.length === 0 ? (
<p className="text-sm text-theme-muted">Sin periodos registrados.</p>
) : (
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl overflow-hidden">
{detalle.periodos.map((p) => (
<li key={p.id} className="px-3 py-2 text-sm flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-theme-heading truncate">
{p.tipo === 'inscripcion' ? 'Inscripción' : `${p.periodo_inicio} a ${p.periodo_fin}`}
</p>
<p className="text-[11px] text-theme-muted">{p.venta || 'sin venta'}{p.fecha_pago ? ` · ${p.fecha_pago}` : ''}</p>
</div>
<div className="text-right shrink-0">
<p className="tabular-nums text-theme-heading">{fmtMoney(p.monto, p.moneda)}</p>
<p className={`text-[11px] ${p.estado === 'pagado' ? 'text-emerald-700' : p.estado === 'vencido' ? 'text-rose-700' : 'text-amber-700'}`}>{p.estado}</p>
</div>
</li>
))}
</ul>
)}
</div>
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Consumo de sesiones</p>
{detalle.consumos.length === 0 ? (
<p className="text-sm text-theme-muted">Todavía no se ha consumido ninguna sesión.</p>
) : (
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl overflow-hidden">
{detalle.consumos.map((c) => (
<li key={c.id} className="px-3 py-2 text-sm flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-theme-heading truncate">{c.servicio || 'Sesión'}{c.sesiones > 1 ? ` × ${c.sesiones}` : ''}</p>
<p className="text-[11px] text-theme-muted">{c.fecha}{c.usuario ? ` · ${c.usuario}` : ''}{c.state === 'cancelled' ? ' · liberada' : ''}</p>
</div>
</li>
))}
</ul>
)}
</div>
{detalle.ventas.length > 0 && (
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Ventas ligadas</p>
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl overflow-hidden">
{detalle.ventas.map((v) => (
<li key={v.id} className="px-3 py-2 text-sm flex items-center justify-between">
<span className="text-theme-heading">{v.name} <span className="text-theme-muted text-[11px]">{v.date}</span></span>
<span className="tabular-nums text-theme-heading">{fmtMoney(v.total, detalle.membresia.moneda)} <span className="text-[11px] text-theme-muted">{v.state}</span></span>
</li>
))}
</ul>
</div>
)}
</div>
)}
</Modal>
{/* Cobro */}
<Modal isOpen={!!cobroTarget} onClose={() => setCobroTarget(null)}
title={cobroTarget ? `Cobrar ${cobroTarget.plan} · ${cobroTarget.patient}` : ''}>
{cobroTarget && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Método</label>
<Select options={METODOS} value={cobroForm.metodo}
onChange={(e) => setCobroForm({ ...cobroForm, metodo: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Moneda</label>
<Select options={[{ value: 'MXN', label: 'MXN' }, { value: 'USD', label: 'USD' }]}
value={cobroForm.moneda} onChange={(e) => setCobroForm({ ...cobroForm, moneda: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Monto</label>
<Input type="number" min={0} value={cobroForm.monto}
onChange={(e) => setCobroForm({ ...cobroForm, monto: e.target.value })} />
</div>
</div>
{cobroTarget.estado === 'activa' && (
<label className="flex items-center gap-2 text-sm text-theme-heading">
<input type="checkbox" checked={cobroForm.cobrar_inscripcion}
onChange={(e) => setCobroForm({ ...cobroForm, cobrar_inscripcion: e.target.checked })}
className="rounded border-theme-border-strong" />
Cobrar también la inscripción
</label>
)}
<p className="text-xs text-theme-muted">Se registra una venta y un pago normales; el corte de caja los verá como cualquier cobro.</p>
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" onClick={() => setCobroTarget(null)}>Cancelar</Button>
<Button onClick={cobrar} disabled={accionando}>{accionando ? 'Cobrando…' : 'Cobrar'}</Button>
</div>
</div>
)}
</Modal>
{/* Congelar */}
<Modal isOpen={!!congelarTarget} onClose={() => setCongelarTarget(null)}
title={congelarTarget ? `Congelar ${congelarTarget.plan} · ${congelarTarget.patient}` : ''}>
{congelarTarget && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Desde</label>
<Input type="date" value={congelarForm.desde} onChange={(e) => setCongelarForm({ ...congelarForm, desde: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Hasta</label>
<Input type="date" value={congelarForm.hasta} onChange={(e) => setCongelarForm({ ...congelarForm, hasta: e.target.value })} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Motivo</label>
<Input value={congelarForm.motivo} onChange={(e) => setCongelarForm({ ...congelarForm, motivo: e.target.value })} />
</div>
<p className="text-xs text-theme-muted">Días congelados este año: {congelarTarget.dias_congelados_anio}. El límite lo define la configuración.</p>
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" onClick={() => setCongelarTarget(null)}>Cancelar</Button>
<Button onClick={congelar} disabled={accionando}>{accionando ? 'Congelando…' : 'Congelar'}</Button>
</div>
</div>
)}
</Modal>
{/* Alta */}
<Modal isOpen={nuevaOpen} onClose={() => setNuevaOpen(false)} title="Nueva membresía">
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">{term('patient_singular')} *</label>
{paciente ? (
<div className="flex items-center justify-between rounded-xl border border-theme-border px-3 py-2">
<span className="text-sm text-theme-heading">{paciente.name}{paciente.phone ? ` · ${paciente.phone}` : ''}</span>
<Button variant="outline" size="sm" onClick={() => setPaciente(null)}>Cambiar</Button>
</div>
) : (
<>
<Input value={pacienteQuery} onChange={(e) => setPacienteQuery(e.target.value)}
placeholder={`Buscar ${term('patient_singular').toLowerCase()} por nombre o teléfono`} />
{pacienteResults.length > 0 && (
<ul className="mt-1 max-h-40 overflow-y-auto rounded-xl border border-theme-border divide-y divide-theme-border">
{pacienteResults.map((p) => (
<li key={p.id}>
<button type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-theme-bg"
onClick={() => { setPaciente(p); setPacienteQuery(''); setPacienteResults([]); }}>
<span className="text-theme-heading">{p.name}</span>
{p.phone && <span className="text-theme-muted text-[11px] ml-2">{p.phone}</span>}
</button>
</li>
))}
</ul>
)}
</>
)}
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Plan *</label>
<Select
options={[{ value: '', label: 'Selecciona un plan…' },
...planes.map((p) => ({ value: String(p.id), label: `${p.name} · ${fmtMoney(p.precio, p.moneda)} / ${p.periodicidad}` }))]}
value={nueva.plan_id} onChange={(e) => setNueva({ ...nueva, plan_id: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Inicio</label>
<Input type="date" value={nueva.fecha_inicio} onChange={(e) => setNueva({ ...nueva, fecha_inicio: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Descuento (%)</label>
<Input type="number" min={0} max={100} value={nueva.descuento}
onChange={(e) => setNueva({ ...nueva, descuento: e.target.value })} placeholder="Por defecto del plan" />
</div>
</div>
<label className="flex items-center gap-2 text-sm text-theme-heading">
<input type="checkbox" checked={nueva.renovacion_automatica}
onChange={(e) => setNueva({ ...nueva, renovacion_automatica: e.target.checked })}
className="rounded border-theme-border-strong" />
Renovación automática
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setNuevaOpen(false)}>Cancelar</Button>
<Button onClick={crear} disabled={accionando}>{accionando ? 'Creando…' : 'Crear membresía'}</Button>
</div>
</div>
</Modal>
</Layout>
);
};
export default Membresias;

View File

@@ -11,6 +11,7 @@ import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
import RecargarSaldoModal from '../components/RecargarSaldoModal';
import ReciboPrint from '../components/ReciboPrint';
import FormulariosPaciente from '../components/FormulariosPaciente';
import MembresiasPaciente from '../components/MembresiasPaciente';
import EnviarFormularioModal from '../components/formulario/EnviarFormularioModal';
import { usePatientDrawer } from '../components/PatientDrawer';
import {
@@ -1126,6 +1127,12 @@ const Pacientes: FC = () => {
<p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.internal_notes}</p>
</div>
)}
{/* Membresías del paciente (solo si la clínica las tiene activas) */}
{selected?.id ? (
<div className="p-3 rounded-xl border border-theme-border">
<MembresiasPaciente partnerId={selected.id} />
</div>
) : null}
</div>
) : tab === 'clinical' ? (
<div className="space-y-4">

View File

@@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package,
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List, Layers,
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List, Layers, Repeat,
} from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
@@ -18,7 +18,7 @@ import {
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete, type Membresia } from '../services/odoo';
import type { PatientPackage } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useClinicSettings } from '../lib/clinic-settings';
@@ -58,6 +58,7 @@ const Pos: FC = () => {
const { openPatient } = usePatientDrawer();
const { can } = useAuth();
const impuestos = useClinicSettings().impuestos;
const membresiasCfg = useClinicSettings().membresias;
const puedeImpuestos = can('ventas.impuestos');
const [searchParams, setSearchParams] = useSearchParams();
// Catálogo
@@ -131,6 +132,8 @@ const Pos: FC = () => {
const [lastSaleLoading, setLastSaleLoading] = useState(false);
// Paquetes del paciente con sesiones pendientes
const [paquetes, setPaquetes] = useState<PatientPackage[]>([]);
// Membresías activas del paciente (solo cuando la clínica las tiene activas)
const [membresias, setMembresias] = useState<Membresia[]>([]);
// Atajos de teclado
const [shortcutsOpen, setShortcutsOpen] = useState(false);
@@ -520,12 +523,52 @@ const Pos: FC = () => {
setPacienteResults([]);
setPacienteAdeudo(0);
setPaquetes([]);
setMembresias([]);
odooApi.getPatientHistory(p.id)
.then((res) => { if (res.status === 'success') setPacienteAdeudo(res.estado_cuenta?.adeudo ?? 0); })
.catch(() => {});
odooApi.getPatientPackages(p.id)
.then((res) => { if (res.status === 'success') setPaquetes(res.packages.filter((k) => k.remaining > 0)); })
.catch(() => {});
// Membresías: solo si la clínica tiene el módulo activo (evita un 403 inútil).
if (membresiasCfg.activo) {
odooApi.getMembresiasPorPaciente(p.id)
.then((res) => { if (res.status === 'success') setMembresias(res.membresias); })
.catch(() => {});
}
};
// Membresía que cubre un servicio: plan activo con el servicio incluido y con
// sesiones disponibles (o ilimitadas). La primera con cobro más próximo gana.
const membresiaParaServicio = (serviceId?: number): Membresia | null => {
if (!membresiasCfg.activo || !serviceId) return null;
const candidatas = membresias
.filter((m) => m.estado === 'activa'
&& m.servicios_incluidos.some((s) => s.id === serviceId)
&& (m.ilimitadas || m.sesiones_restantes > 0))
.sort((a, b) => (a.proximo_cobro || '').localeCompare(b.proximo_cobro || ''));
return candidatas[0] || null;
};
// Usar una sesión de membresía para una línea: NO se cobra el servicio
// (la línea se retira del ticket) y se registra el consumo. Es una acción
// explícita; sin ella el POS se comporta igual que siempre.
const usarSesionMembresia = async (lineKey: string, m: Membresia) => {
const linea = lines.find((l) => l.key === lineKey);
if (!linea || !linea.service_id) return;
try {
const res = await odooApi.consumirMembresia(m.id, {
sesiones: linea.qty, servicio_id: linea.service_id,
notas: 'Consumido desde el POS',
});
if (res.status !== 'success') { toast.error(res.message || 'No se pudo registrar el consumo'); return; }
setLines((prev) => prev.filter((l) => l.key !== lineKey));
setMembresias((prev) => prev.map((x) => (x.id === res.membresia.id ? res.membresia : x)));
toast.success(`Sesión de membresía usada: queda(n) ${res.membresia.ilimitadas ? '∞' : res.membresia.sesiones_restantes}`);
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al usar la sesión de membresía');
}
};
// ---- Repetir última venta (caso típico: toxina cada 3 meses) ----
@@ -723,6 +766,27 @@ const Pos: FC = () => {
))}
</div>
)}
{/* Membresías activas del paciente: aviso + uso de sesión por línea.
Sin membresías (o con el módulo apagado) el POS se ve igual que hoy. */}
{membresiasCfg.activo && membresias.length > 0 && (
<div className="rounded-lg border border-violet-300 bg-violet-50 px-2.5 py-1.5 space-y-0.5">
{membresias.map((m) => (
<p key={m.id} className="text-[11px] text-violet-800 flex items-center gap-1.5">
<Repeat size={11} className="shrink-0" />
<span>
Membresía <span className="font-medium">{m.plan}</span>
{m.ilimitadas
? ' · sesiones ilimitadas'
: ` · ${m.sesiones_restantes} de ${m.sesiones_incluidas_periodo} sesiones este periodo`}
{m.adeudo > 0 ? ` · adeudo ${fmtMoney(m.adeudo)}` : ''}
</span>
</p>
))}
<p className="text-[10px] text-violet-700">
Usa el botón de cada servicio incluido para descontar una sesión sin cobrarlo.
</p>
</div>
)}
{/* Repetir la última venta del paciente (toxina cada 3 meses, etc.) */}
<Button
variant="outline"
@@ -832,6 +896,20 @@ const Pos: FC = () => {
)}
</span>
)}
{(() => {
const mem = membresiaParaServicio(l.service_id);
if (!mem) return null;
return (
<button
type="button"
onClick={() => usarSesionMembresia(l.key, mem)}
className="mt-1 inline-flex items-center gap-1 rounded-full border border-violet-300 bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-800 hover:bg-violet-100"
title={`No cobra el servicio y descuenta una sesión de ${mem.plan}`}
>
<Repeat size={10} /> Usar sesión de membresía
</button>
);
})()}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.key, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>

View File

@@ -29,6 +29,7 @@ import TopClientesReport from '../components/reportes/TopClientesReport';
import ExportarReport from '../components/reportes/ExportarReport';
import HorasAgendaReport from '../components/reportes/HorasAgendaReport';
import PaquetesReport from '../components/reportes/PaquetesReport';
import MembresiasReport from '../components/reportes/MembresiasReport';
import VendedoresReport from '../components/reportes/VendedoresReport';
import ConcentradoReport from '../components/reportes/ConcentradoReport';
import RecomendacionesReport from '../components/reportes/RecomendacionesReport';
@@ -45,6 +46,7 @@ import {
useGlobalReportRange,
useReportFavorites,
} from '../lib/reportRange';
import { useClinicSettings } from '../lib/clinic-settings';
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
@@ -89,6 +91,7 @@ const FAMILIAS: ReportFamily[] = [
{ key: 'pagos-clientes', label: 'Pagos · Clientes', desc: 'Cobros agrupados por paciente' },
{ key: 'recomendaciones', label: 'Recomendaciones', desc: 'Pacientes que llegan recomendados' },
{ key: 'paquetes', label: 'Paquetes', desc: 'Ventas y avance de paquetes' },
{ key: 'membresias', label: 'Membresías', desc: 'Altas, ingresos recurrentes y sesiones consumidas' },
],
},
{
@@ -145,12 +148,17 @@ interface CashReport {
const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { hasRole, canSee, can, brand } = useAuth();
const { membresias: membresiasCfg } = useClinicSettings();
const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
// por pestana y, cuando no existe la del tab, canSee cae al prefijo
// '/reportes'. Asi, quien tiene el modulo completo ve todas y quien tiene una
// concedida suelta (Horas agenda al medico, Exportar a cosmetologia) solo esa.
const puedeTab = useCallback((k: string) => canSee(`/reportes?tab=${k}`), [canSee]);
// La pestaña Membresías además requiere que la clínica tenga el módulo activo.
const puedeTab = useCallback((k: string) => {
if (k === 'membresias' && !membresiasCfg.activo) return false;
return canSee(`/reportes?tab=${k}`);
}, [canSee, membresiasCfg.activo]);
const familiasVisibles = useMemo(
() =>
FAMILIAS
@@ -472,6 +480,8 @@ const ReportesContent: FC = () => {
<HorasAgendaReport />
) : tab === 'paquetes' ? (
<PaquetesReport />
) : tab === 'membresias' ? (
<MembresiasReport />
) : tab === 'vendedores' ? (
<VendedoresReport />
) : tab === 'concentrado' ? (

View File

@@ -1014,6 +1014,7 @@ export interface ClinicSettings {
impuestos: ImpuestosSettings;
reservas_online: ReservasOnlineSettings;
notificaciones: NotificacionesSettings;
membresias: MembresiasSettings;
}
// ---- Reservas en línea (portal público de auto-agendado) ----
@@ -1108,6 +1109,194 @@ export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
{ horas: 168, label: '1 semana' },
];
// ---- Membresías y suscripciones ----
export interface MembresiasSettings {
/** Enciende TODO el módulo: menú, POS, cron y cargos. Apagado = no cambia nada. */
activo: boolean;
/** Días antes del cobro en que se avisa (ordenados). */
dias_aviso_renovacion: number[];
permitir_congelamiento: boolean;
/** Tope de días congelados por año. */
dias_max_congelamiento_anio: number;
renovacion_automatica_default: boolean;
/** Descuento por defecto en servicios no incluidos (%). */
descuento_porcentaje_default: number;
permitir_pausa_pago: boolean;
/** El cron genera el adeudo del nuevo periodo. */
generar_cargo_al_renovar: boolean;
/** Días tras el vencimiento antes de marcar la membresía vencida. */
dias_gracia: number;
}
export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
activo: false,
dias_aviso_renovacion: [7, 1],
permitir_congelamiento: true,
dias_max_congelamiento_anio: 30,
renovacion_automatica_default: false,
descuento_porcentaje_default: 0,
permitir_pausa_pago: false,
generar_cargo_al_renovar: true,
dias_gracia: 5,
};
export type PeriodicidadMembresia = 'semanal' | 'mensual' | 'trimestral' | 'semestral' | 'anual';
export type EstadoMembresia = 'activa' | 'congelada' | 'vencida' | 'pausada' | 'cancelada';
export type EstadoPeriodoMembresia = 'pendiente' | 'pagado' | 'vencido' | 'condonado';
export interface MembresiaPlan {
id: number;
name: string;
descripcion: string;
precio: number;
moneda: 'MXN' | 'USD';
periodicidad: PeriodicidadMembresia;
sesiones_incluidas: number;
servicios_incluidos: { id: number; name: string }[];
categorias_incluidas: { id: number; name: string; code: string }[];
descuento_porcentaje: number;
cargo_inscripcion: number;
meses_minimos: number;
permitir_congelamiento: boolean;
activo: boolean;
sequence: number;
sucursales: { id: number; name: string }[];
servicio_id: number | null;
servicio: string;
notas: string;
membresias_count: number;
}
/** Payload de alta/edición de un plan (el backend recibe ids, no objetos). */
export interface MembresiaPlanInput {
name?: string;
descripcion?: string;
precio?: number;
moneda?: 'MXN' | 'USD';
periodicidad?: PeriodicidadMembresia;
sesiones_incluidas?: number;
servicios_incluidos?: number[];
categorias_incluidas?: number[];
descuento_porcentaje?: number;
cargo_inscripcion?: number;
meses_minimos?: number;
permitir_congelamiento?: boolean;
activo?: boolean;
sequence?: number;
sucursales?: number[];
servicio_id?: number | null;
notas?: string;
}
export interface Membresia {
id: number;
folio: string;
partner_id: number;
patient: string;
phone: string;
plan_id: number | null;
plan: string;
periodicidad: string;
precio: number;
moneda: string;
estado: EstadoMembresia;
estado_label: string;
fecha_inicio: string | null;
fecha_fin: string | null;
periodo_actual_inicio: string | null;
periodo_actual_fin: string | null;
proximo_cobro: string | null;
sesiones_incluidas_periodo: number;
sesiones_usadas_periodo: number;
sesiones_restantes: number;
ilimitadas: boolean;
renovacion_automatica: boolean;
descuento_porcentaje: number;
congelada_desde: string | null;
congelada_hasta: string | null;
dias_congelados_anio: number;
motivo_cancelacion: string;
notas: string;
adeudo: number;
periodos_pagados: number;
dias_para_cobro: number;
vigente: boolean;
servicios_incluidos: { id: number; name: string }[];
}
export interface MembresiaPeriodo {
id: number;
tipo: 'periodo' | 'inscripcion' | 'ajuste';
periodo_inicio: string | null;
periodo_fin: string | null;
monto: number;
moneda: string;
estado: EstadoPeriodoMembresia;
venta_id: number | null;
venta: string;
fecha_pago: string | null;
notas: string;
}
export interface MembresiaConsumo {
id: number;
fecha: string | null;
cita_id: number | null;
venta_id: number | null;
servicio_id: number | null;
servicio: string;
sesiones: number;
usuario: string;
notas: string;
state: 'consumed' | 'cancelled';
}
export interface MembresiaResumen {
activas: number;
por_vencer: number;
vencidas: number;
mrr_estimado: number;
}
export interface MembresiasResponse {
status: string;
membresias: Membresia[];
total: number;
page: number;
page_size: number;
total_pages: number;
resumen: MembresiaResumen;
message?: string;
}
export interface MembresiaDetalleResponse {
status: string;
membresia: Membresia;
periodos: MembresiaPeriodo[];
consumos: MembresiaConsumo[];
ventas: { id: number; name: string; date: string | null; total: number; state: string; payment_method: string }[];
saldos: { adeudo: number; sesiones_restantes: number; ilimitadas: boolean; periodos_pagados: number };
message?: string;
}
export interface MembresiasReporte {
status: string;
start: string;
end: string;
altas: number;
cancelaciones: number;
ingresos_por_mes: { mes: string; total: number }[];
recurrentes_por_mes: { mes: string; total: number }[];
total_ingresos: number;
total_recurrente: number;
proximas_renovaciones: { id: number; folio: string; patient: string; plan: string; proximo_cobro: string | null; precio: number; moneda: string }[];
sesiones_consumidas: number;
sesiones_incluidas_activas: number;
membresias_activas: number;
message?: string;
}
export interface Notificacion {
id: number;
partner_id: number;
@@ -1351,6 +1540,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
impuestos: IMPUESTOS_DEFAULT,
reservas_online: RESERVAS_ONLINE_DEFAULT,
notificaciones: NOTIFICACIONES_DEFAULT,
membresias: MEMBRESIAS_DEFAULT,
};
export interface FrontendUser {
@@ -2011,6 +2201,96 @@ export const odooApi = {
return data;
},
// ---- Membresías y suscripciones ----
// Con `membresias.activo=false` (default) el backend responde 403 y estas
// llamadas nunca se hacen desde la UI (la sección no se monta).
async getMembresiaPlanes(opts?: { includeInactive?: boolean; q?: string }): Promise<{ status: string; planes: MembresiaPlan[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes`, {
params: { ...(opts?.includeInactive ? { include_inactive: 1 } : {}), ...(opts?.q ? { q: opts.q } : {}) },
});
return data;
},
async createMembresiaPlan(payload: MembresiaPlanInput): Promise<{ status: string; plan: MembresiaPlan; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes`, payload);
return data;
},
async updateMembresiaPlan(id: number, payload: MembresiaPlanInput): Promise<{ status: string; plan: MembresiaPlan; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes/${id}`, payload);
return data;
},
async deleteMembresiaPlan(id: number): Promise<{ status: string; message?: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes/${id}`);
return data;
},
async getMembresias(params?: { estado?: string; plan_id?: number; partner_id?: number; por_vencer?: boolean; q?: string; page?: number; page_size?: number }): Promise<MembresiasResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias`, {
params: {
...(params?.estado ? { estado: params.estado } : {}),
...(params?.plan_id ? { plan_id: params.plan_id } : {}),
...(params?.partner_id ? { partner_id: params.partner_id } : {}),
...(params?.por_vencer ? { por_vencer: 1 } : {}),
...(params?.q ? { q: params.q } : {}),
...(params?.page ? { page: params.page } : {}),
...(params?.page_size ? { page_size: params.page_size } : {}),
},
});
return data;
},
async getMembresia(id: number): Promise<MembresiaDetalleResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}`);
return data;
},
async getMembresiasPorPaciente(partnerId: number): Promise<{ status: string; membresias: Membresia[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/por_paciente`, { params: { partner_id: partnerId } });
return data;
},
async crearMembresia(payload: { partner_id: number; plan_id: number; fecha_inicio?: string; renovacion_automatica?: boolean; descuento_porcentaje?: number; notas?: string }): Promise<{ status: string; membresia: Membresia; inscripcion_cargo: number; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias`, payload);
return data;
},
async congelarMembresia(id: number, payload: { desde: string; hasta: string; motivo?: string }): Promise<{ status: string; membresia: Membresia; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/congelar`, payload);
return data;
},
async descongelarMembresia(id: number): Promise<{ status: string; membresia: Membresia; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/descongelar`, {});
return data;
},
async renovarMembresia(id: number, generarCargo?: boolean): Promise<{ status: string; membresia: Membresia; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/renovar`, { generar_cargo: generarCargo });
return data;
},
async cancelarMembresia(id: number, motivo: string): Promise<{ status: string; membresia: Membresia; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/cancelar`, { motivo });
return data;
},
async cobrarMembresia(id: number, payload: { metodo: string; monto?: number; moneda?: string; cobrar_inscripcion?: boolean }): Promise<{ status: string; membresia: Membresia; venta: { id: number; name: string; total: number; state: string }; pago: { id: number; name: string; amount: number; payment_method: string }; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/cobrar`, payload);
return data;
},
async consumirMembresia(id: number, payload: { sesiones?: number; cita_id?: number; venta_id?: number; servicio_id?: number; notas?: string }): Promise<{ status: string; consumo: MembresiaConsumo; membresia: Membresia; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/consumir`, payload);
return data;
},
async getMembresiasReporte(params: { start: string; end: string }): Promise<MembresiasReporte> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/reporte`, { params });
return data;
},
// ---- Portal público de reservas en línea (sin autenticación) ----
async getBookingConfig(): Promise<BookingConfig> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/booking/config`);