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:
@@ -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>} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
223
frontend/src/components/MembresiasPaciente.tsx
Normal file
223
frontend/src/components/MembresiasPaciente.tsx
Normal 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;
|
||||
456
frontend/src/components/MembresiasPanel.tsx
Normal file
456
frontend/src/components/MembresiasPanel.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
171
frontend/src/components/reportes/MembresiasReport.tsx
Normal file
171
frontend/src/components/reportes/MembresiasReport.tsx
Normal 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;
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
611
frontend/src/pages/Membresias.tsx
Normal file
611
frontend/src/pages/Membresias.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user