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

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

View File

@@ -10,6 +10,7 @@ import { ServiceCategoriesProvider } from './lib/service-categories';
import { QuickLinksProvider } from './lib/quicklinks'; import { QuickLinksProvider } from './lib/quicklinks';
import { PatientDrawerProvider } from './components/PatientDrawer'; import { PatientDrawerProvider } from './components/PatientDrawer';
import ChatNotifications from './lib/chat-notifications'; import ChatNotifications from './lib/chat-notifications';
import { useClinicSettings } from './lib/clinic-settings';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Agenda from './pages/Agenda'; import Agenda from './pages/Agenda';
import Pacientes from './pages/Pacientes'; import Pacientes from './pages/Pacientes';
@@ -18,6 +19,7 @@ import Visitas from './pages/Visitas';
import Consultas from './pages/Consultas'; import Consultas from './pages/Consultas';
import Servicios from './pages/Servicios'; import Servicios from './pages/Servicios';
import Paquetes from './pages/Paquetes'; import Paquetes from './pages/Paquetes';
import Membresias from './pages/Membresias';
import Medicos from './pages/Medicos'; import Medicos from './pages/Medicos';
import Monedero from './pages/Monedero'; import Monedero from './pages/Monedero';
import Pagos from './pages/Pagos'; import Pagos from './pages/Pagos';
@@ -109,6 +111,15 @@ const LANDING_CANDIDATOS = [
'/wacrm/leads', '/configuracion', '/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 HomeRoute: FC = () => {
const { user, canSee } = useAuth(); const { user, canSee } = useAuth();
if (user?.role === 'medico') return <Navigate to="/agenda" replace />; 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="/servicios" element={<RequireAuth href="/servicios"><Servicios /></RequireAuth>} />
<Route path="/medicos" element={<RequireAuth href="/medicos"><Medicos /></RequireAuth>} /> <Route path="/medicos" element={<RequireAuth href="/medicos"><Medicos /></RequireAuth>} />
<Route path="/paquetes" element={<RequireAuth href="/paquetes"><Paquetes /></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="/productos" element={<Navigate to="/inventario" replace />} />
<Route path="/monedero" element={<RequireAuth href="/monedero"><Monedero /></RequireAuth>} /> <Route path="/monedero" element={<RequireAuth href="/monedero"><Monedero /></RequireAuth>} />
<Route path="/pagos" element={<RequireAuth href="/pagos"><Pagos /></RequireAuth>} /> <Route path="/pagos" element={<RequireAuth href="/pagos"><Pagos /></RequireAuth>} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ import {
Layers, Layers,
Clock, Clock,
Download, Download,
Repeat,
} from 'lucide-react'; } from 'lucide-react';
import { Button, Modal, Input } from './ui'; import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -46,6 +47,7 @@ import ThemePicker from './ThemePicker';
import NotificationBell from './NotificationBell'; import NotificationBell from './NotificationBell';
import { useAuth, ROLE_LABELS } from '../lib/auth'; import { useAuth, ROLE_LABELS } from '../lib/auth';
import { useGlosario, traducirMenu } from '../lib/glosario'; import { useGlosario, traducirMenu } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings';
import { useChatUnread } from '../lib/chat'; import { useChatUnread } from '../lib/chat';
import { useQuickLinks } from '../lib/quicklinks'; import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo'; 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 }) => { export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth(); const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario(); 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 [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread(); const chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas // Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas

View File

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

View File

@@ -37,6 +37,9 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/medicos': ['admin', 'recepcion', 'gerencia'], '/medicos': ['admin', 'recepcion', 'gerencia'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'], '/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia', 'cosmetologia'], '/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 '/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario
'/ventas': ['admin', 'recepcion', 'gerencia'], '/ventas': ['admin', 'recepcion', 'gerencia'],
'/tickets': ['admin', 'gerencia'], '/tickets': ['admin', 'gerencia'],
@@ -49,6 +52,7 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=paquetes': ['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=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
import RecargarSaldoModal from '../components/RecargarSaldoModal'; import RecargarSaldoModal from '../components/RecargarSaldoModal';
import ReciboPrint from '../components/ReciboPrint'; import ReciboPrint from '../components/ReciboPrint';
import FormulariosPaciente from '../components/FormulariosPaciente'; import FormulariosPaciente from '../components/FormulariosPaciente';
import MembresiasPaciente from '../components/MembresiasPaciente';
import EnviarFormularioModal from '../components/formulario/EnviarFormularioModal'; import EnviarFormularioModal from '../components/formulario/EnviarFormularioModal';
import { usePatientDrawer } from '../components/PatientDrawer'; import { usePatientDrawer } from '../components/PatientDrawer';
import { import {
@@ -1126,6 +1127,12 @@ const Pacientes: FC = () => {
<p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.internal_notes}</p> <p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.internal_notes}</p>
</div> </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> </div>
) : tab === 'clinical' ? ( ) : tab === 'clinical' ? (
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { import {
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package, 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'; } from 'lucide-react';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer'; import { usePatientDrawer } from '../components/PatientDrawer';
@@ -18,7 +18,7 @@ import {
Skeleton, Skeleton,
toast, toast,
} from '../components/ui'; } 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 type { PatientPackage } from '../services/odoo';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
import { useClinicSettings } from '../lib/clinic-settings'; import { useClinicSettings } from '../lib/clinic-settings';
@@ -58,6 +58,7 @@ const Pos: FC = () => {
const { openPatient } = usePatientDrawer(); const { openPatient } = usePatientDrawer();
const { can } = useAuth(); const { can } = useAuth();
const impuestos = useClinicSettings().impuestos; const impuestos = useClinicSettings().impuestos;
const membresiasCfg = useClinicSettings().membresias;
const puedeImpuestos = can('ventas.impuestos'); const puedeImpuestos = can('ventas.impuestos');
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
// Catálogo // Catálogo
@@ -131,6 +132,8 @@ const Pos: FC = () => {
const [lastSaleLoading, setLastSaleLoading] = useState(false); const [lastSaleLoading, setLastSaleLoading] = useState(false);
// Paquetes del paciente con sesiones pendientes // Paquetes del paciente con sesiones pendientes
const [paquetes, setPaquetes] = useState<PatientPackage[]>([]); 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 // Atajos de teclado
const [shortcutsOpen, setShortcutsOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false);
@@ -520,12 +523,52 @@ const Pos: FC = () => {
setPacienteResults([]); setPacienteResults([]);
setPacienteAdeudo(0); setPacienteAdeudo(0);
setPaquetes([]); setPaquetes([]);
setMembresias([]);
odooApi.getPatientHistory(p.id) odooApi.getPatientHistory(p.id)
.then((res) => { if (res.status === 'success') setPacienteAdeudo(res.estado_cuenta?.adeudo ?? 0); }) .then((res) => { if (res.status === 'success') setPacienteAdeudo(res.estado_cuenta?.adeudo ?? 0); })
.catch(() => {}); .catch(() => {});
odooApi.getPatientPackages(p.id) odooApi.getPatientPackages(p.id)
.then((res) => { if (res.status === 'success') setPaquetes(res.packages.filter((k) => k.remaining > 0)); }) .then((res) => { if (res.status === 'success') setPaquetes(res.packages.filter((k) => k.remaining > 0)); })
.catch(() => {}); .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) ---- // ---- Repetir última venta (caso típico: toxina cada 3 meses) ----
@@ -723,6 +766,27 @@ const Pos: FC = () => {
))} ))}
</div> </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.) */} {/* Repetir la última venta del paciente (toxina cada 3 meses, etc.) */}
<Button <Button
variant="outline" variant="outline"
@@ -832,6 +896,20 @@ const Pos: FC = () => {
)} )}
</span> </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>
<div className="flex items-center gap-0.5 shrink-0"> <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> <Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.key, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>

View File

@@ -29,6 +29,7 @@ import TopClientesReport from '../components/reportes/TopClientesReport';
import ExportarReport from '../components/reportes/ExportarReport'; import ExportarReport from '../components/reportes/ExportarReport';
import HorasAgendaReport from '../components/reportes/HorasAgendaReport'; import HorasAgendaReport from '../components/reportes/HorasAgendaReport';
import PaquetesReport from '../components/reportes/PaquetesReport'; import PaquetesReport from '../components/reportes/PaquetesReport';
import MembresiasReport from '../components/reportes/MembresiasReport';
import VendedoresReport from '../components/reportes/VendedoresReport'; import VendedoresReport from '../components/reportes/VendedoresReport';
import ConcentradoReport from '../components/reportes/ConcentradoReport'; import ConcentradoReport from '../components/reportes/ConcentradoReport';
import RecomendacionesReport from '../components/reportes/RecomendacionesReport'; import RecomendacionesReport from '../components/reportes/RecomendacionesReport';
@@ -45,6 +46,7 @@ import {
useGlobalReportRange, useGlobalReportRange,
useReportFavorites, useReportFavorites,
} from '../lib/reportRange'; } from '../lib/reportRange';
import { useClinicSettings } from '../lib/clinic-settings';
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981']; 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: 'pagos-clientes', label: 'Pagos · Clientes', desc: 'Cobros agrupados por paciente' },
{ key: 'recomendaciones', label: 'Recomendaciones', desc: 'Pacientes que llegan recomendados' }, { key: 'recomendaciones', label: 'Recomendaciones', desc: 'Pacientes que llegan recomendados' },
{ key: 'paquetes', label: 'Paquetes', desc: 'Ventas y avance de paquetes' }, { 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 ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { hasRole, canSee, can, brand } = useAuth(); const { hasRole, canSee, can, brand } = useAuth();
const { membresias: membresiasCfg } = useClinicSettings();
const esAdmin = hasRole('admin'); const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves // PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
// por pestana y, cuando no existe la del tab, canSee cae al prefijo // 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 // '/reportes'. Asi, quien tiene el modulo completo ve todas y quien tiene una
// concedida suelta (Horas agenda al medico, Exportar a cosmetologia) solo esa. // 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( const familiasVisibles = useMemo(
() => () =>
FAMILIAS FAMILIAS
@@ -472,6 +480,8 @@ const ReportesContent: FC = () => {
<HorasAgendaReport /> <HorasAgendaReport />
) : tab === 'paquetes' ? ( ) : tab === 'paquetes' ? (
<PaquetesReport /> <PaquetesReport />
) : tab === 'membresias' ? (
<MembresiasReport />
) : tab === 'vendedores' ? ( ) : tab === 'vendedores' ? (
<VendedoresReport /> <VendedoresReport />
) : tab === 'concentrado' ? ( ) : tab === 'concentrado' ? (

View File

@@ -1014,6 +1014,7 @@ export interface ClinicSettings {
impuestos: ImpuestosSettings; impuestos: ImpuestosSettings;
reservas_online: ReservasOnlineSettings; reservas_online: ReservasOnlineSettings;
notificaciones: NotificacionesSettings; notificaciones: NotificacionesSettings;
membresias: MembresiasSettings;
} }
// ---- Reservas en línea (portal público de auto-agendado) ---- // ---- 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' }, { 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 { export interface Notificacion {
id: number; id: number;
partner_id: number; partner_id: number;
@@ -1351,6 +1540,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
impuestos: IMPUESTOS_DEFAULT, impuestos: IMPUESTOS_DEFAULT,
reservas_online: RESERVAS_ONLINE_DEFAULT, reservas_online: RESERVAS_ONLINE_DEFAULT,
notificaciones: NOTIFICACIONES_DEFAULT, notificaciones: NOTIFICACIONES_DEFAULT,
membresias: MEMBRESIAS_DEFAULT,
}; };
export interface FrontendUser { export interface FrontendUser {
@@ -2011,6 +2201,96 @@ export const odooApi = {
return data; 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) ---- // ---- Portal público de reservas en línea (sin autenticación) ----
async getBookingConfig(): Promise<BookingConfig> { async getBookingConfig(): Promise<BookingConfig> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/booking/config`); const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/booking/config`);

View File

@@ -316,16 +316,25 @@ NOTIFICACIONES_DEFAULT = {
'Hola {nombre}, te compartimos el formulario "{formulario}" para completar. ' 'Hola {nombre}, te compartimos el formulario "{formulario}" para completar. '
'Ábrelo desde tu celular aquí: {enlace}' 'Ábrelo desde tu celular aquí: {enlace}'
), ),
# Aviso de renovación de una membresía (bloque `membresias`). Usa variables
# propias ({plan} {monto} {sesiones} {periodo}) que el motor recibe como
# contexto extra al encolar; ver `skeen.membresia.service`.
'plantilla_renovacion_membresia': (
'Hola {nombre}, tu plan {plan} se renueva el {fecha} ({periodo}) por '
'{monto}. Tienes {sesiones} sesiones este periodo. {sucursal}.'
),
'whatsapp_numero': '', 'whatsapp_numero': '',
'email_remitente': '', 'email_remitente': '',
} }
NOTIF_CANALES = ('whatsapp', 'email', 'ambos') NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
'sucursal', 'telefono', 'enlace', 'formulario') 'sucursal', 'telefono', 'enlace', 'formulario',
# Variables del aviso de membresías.
'plan', 'membresia', 'monto', 'sesiones', 'periodo')
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion', NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
'plantilla_cumpleanos', 'plantilla_post_visita', 'plantilla_cumpleanos', 'plantilla_post_visita',
'plantilla_formulario') 'plantilla_formulario', 'plantilla_renovacion_membresia')
NOTIF_HORAS_MAX = 168 # 1 semana NOTIF_HORAS_MAX = 168 # 1 semana
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
@@ -484,6 +493,119 @@ def get_notificaciones(env):
"""Bloque de notificaciones vigente (copia). Fuente única para el motor.""" """Bloque de notificaciones vigente (copia). Fuente única para el motor."""
return normalizar_notificaciones(get_settings(env).get('notificaciones')) return normalizar_notificaciones(get_settings(env).get('notificaciones'))
# ---------------------------------------------------------------------------
# Membresías y suscripciones.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
# Membresías no aparece en el menú, el POS no ofrece sesiones de membresía, no
# corre el cron y no se genera ningún cargo ni aviso. Por eso el default es
# apagado.
#
# activo: enciende TODO el módulo de membresías
# dias_aviso_renovacion: días antes del cobro en que se avisa
# permitir_congelamiento: permite congelar membresías
# dias_max_congelamiento_anio: tope de días congelados por año
# renovacion_automatica_default: valor por defecto al dar de alta
# descuento_porcentaje_default: descuento por defecto en servicios no incluidos
# permitir_pausa_pago: permite pausar la membresía por falta de pago
# generar_cargo_al_renovar: el cron genera el adeudo del periodo
# dias_gracia: días tras el vencimiento antes de marcarla vencida
# ---------------------------------------------------------------------------
MEMBRESIAS_DEFAULT = {
'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,
}
MEMBRESIAS_DIAS_MAX = 365 # tope del límite de congelamiento por año
MEMBRESIAS_AVISO_MAX = 365 # tope de días de aviso de renovación
def normalizar_membresias(raw):
"""Bloque `membresias` completo, con defaults. Tolerante: nunca lanza."""
out = dict(MEMBRESIAS_DEFAULT)
out['dias_aviso_renovacion'] = list(MEMBRESIAS_DEFAULT['dias_aviso_renovacion'])
if not isinstance(raw, dict):
return out
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
'permitir_pausa_pago', 'generar_cargo_al_renovar'):
out[k] = _bool(raw.get(k), MEMBRESIAS_DEFAULT[k])
out['dias_aviso_renovacion'] = horas_recordatorio_validas(
raw.get('dias_aviso_renovacion'), MEMBRESIAS_DEFAULT['dias_aviso_renovacion'])
out['dias_max_congelamiento_anio'] = min(max(_int(
raw.get('dias_max_congelamiento_anio'),
MEMBRESIAS_DEFAULT['dias_max_congelamiento_anio']), 0), MEMBRESIAS_DIAS_MAX)
out['descuento_porcentaje_default'] = min(max(_num(
raw.get('descuento_porcentaje_default'),
MEMBRESIAS_DEFAULT['descuento_porcentaje_default']), 0.0), 100.0)
out['dias_gracia'] = min(max(_int(raw.get('dias_gracia'),
MEMBRESIAS_DEFAULT['dias_gracia']), 0), 365)
return out
def validar_membresias(raw):
"""Valida el bloque `membresias` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de membresías debe ser un objeto')
out = {}
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
'permitir_pausa_pago', 'generar_cargo_al_renovar'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'dias_aviso_renovacion' in raw:
valor = raw['dias_aviso_renovacion']
if not isinstance(valor, list) or not valor:
raise ValueError('Los días de aviso de renovación deben ser una lista')
dias = []
for v in valor:
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Cada día de aviso debe ser un número entero')
if not (0 <= v <= MEMBRESIAS_AVISO_MAX):
raise ValueError('Los días de aviso deben estar entre 0 y %d' % MEMBRESIAS_AVISO_MAX)
if v not in dias:
dias.append(v)
out['dias_aviso_renovacion'] = sorted(set(dias))
if 'dias_max_congelamiento_anio' in raw:
if isinstance(raw['dias_max_congelamiento_anio'], bool) or \
not isinstance(raw['dias_max_congelamiento_anio'], int):
raise ValueError('El máximo de días de congelamiento debe ser un número entero')
if not (0 <= raw['dias_max_congelamiento_anio'] <= MEMBRESIAS_DIAS_MAX):
raise ValueError('El máximo de días de congelamiento debe estar entre 0 y %d'
% MEMBRESIAS_DIAS_MAX)
out['dias_max_congelamiento_anio'] = raw['dias_max_congelamiento_anio']
if 'descuento_porcentaje_default' in raw:
n = _num(raw['descuento_porcentaje_default'], None)
if n is None:
raise ValueError('El descuento por defecto debe ser un número')
if not (0 <= n <= 100):
raise ValueError('El descuento por defecto debe estar entre 0 y 100')
out['descuento_porcentaje_default'] = n
if 'dias_gracia' in raw:
if isinstance(raw['dias_gracia'], bool) or not isinstance(raw['dias_gracia'], int):
raise ValueError('Los días de gracia deben ser un número entero')
if not (0 <= raw['dias_gracia'] <= 365):
raise ValueError('Los días de gracia deben estar entre 0 y 365')
out['dias_gracia'] = raw['dias_gracia']
return out
def get_membresias(env):
"""Bloque de membresías vigente (copia). Fuente única para modelos y API."""
return normalizar_membresias(get_settings(env).get('membresias'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00). # Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0 AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0 AGENDA_END_DEFAULT = 19.0
@@ -526,6 +648,7 @@ def get_settings(env):
'impuestos': dict(IMPUESTOS_DEFAULT), 'impuestos': dict(IMPUESTOS_DEFAULT),
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT), 'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
'notificaciones': normalizar_notificaciones(None), 'notificaciones': normalizar_notificaciones(None),
'membresias': normalizar_membresias(None),
} }
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY) raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw: if raw:
@@ -540,6 +663,7 @@ def get_settings(env):
out['impuestos'] = normalizar_impuestos(guardado.get('impuestos')) out['impuestos'] = normalizar_impuestos(guardado.get('impuestos'))
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online')) out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones')) out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
out['membresias'] = normalizar_membresias(guardado.get('membresias'))
return out return out
@@ -564,6 +688,10 @@ def set_settings(env, vals):
bloque = validar_notificaciones(vals['notificaciones']) bloque = validar_notificaciones(vals['notificaciones'])
if bloque is not None: if bloque is not None:
actual['notificaciones'] = {**actual['notificaciones'], **bloque} actual['notificaciones'] = {**actual['notificaciones'], **bloque}
if 'membresias' in vals:
bloque = validar_membresias(vals['membresias'])
if bloque is not None:
actual['membresias'] = {**actual['membresias'], **bloque}
env['ir.config_parameter'].sudo().set_param( env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False)) SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env) return get_settings(env)

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Membresías y Suscripciones',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Membresías recurrentes: planes, cobro periódico, sesiones incluidas, congelamiento y reportes',
'description': """
Membresías y suscripciones para cualquier giro (spa, clínica, estética).
Una membresía es un plan recurrente con vigencia (semanal..anual),
sesiones incluidas por periodo, cobro recurrente, congelamiento y
renovación. NO es un paquete: el paquete es prepago de N sesiones.
El módulo está APAGADO por defecto (`membresias.activo=false`): no
aparece en el menú, el POS no cambia, no corre ningún cron y no se
genera ningún cargo hasta que la clínica lo encienda.
Los cobros se registran como ventas y pagos normales (`skeen.venta`)
para que los cortes de caja, el concentrado y las comisiones los vean
como cualquier otra operación.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': ['base', 'mail', 'skeen_citas', 'skeen_pagos', 'skeen_ventas', 'skeen_whatsapp'],
'data': [
'security/ir.model.access.csv',
'data/sequences.xml',
'views/membresia_views.xml',
'data/membresias_cron.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import membresias

View File

@@ -0,0 +1,717 @@
# -*- coding: utf-8 -*-
"""API del frontend React para Membresías y suscripciones.
Vive en su propio controlador (no en `skeen_whatsapp/controllers/frontend.py`)
para no chocar con otros trabajos. Todas las rutas cuelgan de
`/skeen/frontend/v1/membresias` y exigen token (`@require_role`) más el permiso
por acción (`@require_perm`).
Regla de oro: con `membresias.activo=False` TODAS las rutas responden 403 y no
tocan nada, así el despliegue en una instancia apagada no cambia ningún
comportamiento.
"""
import functools
import json
from collections import defaultdict
from datetime import date, datetime, timedelta
from odoo import fields, http
from odoo.exceptions import ValidationError
from odoo.http import request, Response
from odoo.addons.skeen_whatsapp.controllers.auth import require_perm, require_role
from odoo.addons.skeen_citas.clinic_settings import get_membresias
BASE = '/skeen/frontend/v1/membresias'
# Semanas por mes para normalizar el precio de un plan a "ingreso mensual
# estimado" (MRR). Es una estimación de dirección, no contabilidad.
MESES_POR_PERIODICIDAD = {
'semanal': 52.0 / 12.0,
'mensual': 1.0,
'trimestral': 1.0 / 3.0,
'semestral': 1.0 / 6.0,
'anual': 1.0 / 12.0,
}
ESTADOS = ('activa', 'congelada', 'vencida', 'pausada', 'cancelada')
ESTADO_LABEL = {
'activa': 'Activa', 'congelada': 'Congelada', 'vencida': 'Vencida',
'pausada': 'Pausada', 'cancelada': 'Cancelada',
}
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
)
def _body():
try:
raw = request.httprequest.get_data(as_text=True)
return json.loads(raw) if raw else {}
except Exception:
return {}
def _cfg():
return get_membresias(request.env)
def _activo():
return bool(_cfg().get('activo'))
def membresias_activas(func):
"""Niega la operación si la instancia no tiene el módulo encendido."""
@functools.wraps(func)
def wrapper(self, *args, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if not _activo():
return _json({'status': 'error',
'message': 'Membresías no está activado en esta clínica.'}, 403)
return func(self, *args, **kw)
return wrapper
def _login():
return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
class SkeenMembresiasController(http.Controller):
# ------------------------------------------------------------------
# Serializadores
# ------------------------------------------------------------------
def _plan_to_dict(self, p):
return {
'id': p.id,
'name': p.name,
'descripcion': p.descripcion or '',
'precio': p.precio,
'moneda': p.moneda,
'periodicidad': p.periodicidad,
'sesiones_incluidas': p.sesiones_incluidas,
'servicios_incluidos': [{'id': s.id, 'name': s.name}
for s in p.servicios_incluidos],
'categorias_incluidas': [{'id': c.id, 'name': c.name, 'code': c.code}
for c in p.categorias_incluidas],
'descuento_porcentaje': p.descuento_porcentaje,
'cargo_inscripcion': p.cargo_inscripcion,
'meses_minimos': p.meses_minimos,
'permitir_congelamiento': p.permitir_congelamiento,
'activo': p.activo,
'sequence': p.sequence,
'sucursales': [{'id': s.id, 'name': s.name} for s in p.sucursales],
'servicio_id': p.servicio_cobro().id if p.servicio_cobro() else None,
'servicio': p.servicio_cobro().name if p.servicio_cobro() else '',
'notas': p.notas or '',
'membresias_count': p.membresias_count,
}
def _membresia_to_dict(self, m):
return {
'id': m.id,
'folio': m.folio or '',
'partner_id': m.partner_id.id,
'patient': m.partner_id.name or '',
'phone': m.partner_id.phone or '',
'plan_id': m.plan_id.id if m.plan_id else None,
'plan': m.plan_id.name if m.plan_id else '',
'periodicidad': m.plan_id.periodicidad if m.plan_id else '',
'precio': m.plan_id.precio if m.plan_id else 0.0,
'moneda': m.plan_id.moneda if m.plan_id else 'MXN',
'estado': m.estado,
'estado_label': ESTADO_LABEL.get(m.estado, m.estado),
'fecha_inicio': m.fecha_inicio.strftime('%Y-%m-%d') if m.fecha_inicio else None,
'fecha_fin': m.fecha_fin.strftime('%Y-%m-%d') if m.fecha_fin else None,
'periodo_actual_inicio': m.periodo_actual_inicio.strftime('%Y-%m-%d')
if m.periodo_actual_inicio else None,
'periodo_actual_fin': m.periodo_actual_fin.strftime('%Y-%m-%d')
if m.periodo_actual_fin else None,
'proximo_cobro': m.proximo_cobro.strftime('%Y-%m-%d') if m.proximo_cobro else None,
'sesiones_incluidas_periodo': m.sesiones_incluidas_periodo,
'sesiones_usadas_periodo': m.sesiones_usadas_periodo,
'sesiones_restantes': m.sesiones_restantes,
'ilimitadas': m.ilimitadas,
'renovacion_automatica': m.renovacion_automatica,
'descuento_porcentaje': m.descuento_porcentaje,
'congelada_desde': m.congelada_desde.strftime('%Y-%m-%d') if m.congelada_desde else None,
'congelada_hasta': m.congelada_hasta.strftime('%Y-%m-%d') if m.congelada_hasta else None,
'dias_congelados_anio': m.dias_congelados_anio,
'motivo_cancelacion': m.motivo_cancelacion or '',
'notas': m.notas or '',
'adeudo': m.adeudo,
'periodos_pagados': m.periodos_pagados,
'dias_para_cobro': m.dias_para_cobro,
'vigente': m.vigente,
'servicios_incluidos': [
{'id': s.id, 'name': s.name} for s in m.plan_id.servicios_incluidos
] if m.plan_id else [],
}
def _periodo_to_dict(self, p):
return {
'id': p.id,
'tipo': p.tipo,
'periodo_inicio': p.periodo_inicio.strftime('%Y-%m-%d') if p.periodo_inicio else None,
'periodo_fin': p.periodo_fin.strftime('%Y-%m-%d') if p.periodo_fin else None,
'monto': p.monto,
'moneda': p.moneda,
'estado': p.estado,
'venta_id': p.venta_id.id if p.venta_id else None,
'venta': p.venta_id.name if p.venta_id else '',
'fecha_pago': p.fecha_pago.strftime('%Y-%m-%d %H:%M') if p.fecha_pago else None,
'notas': p.notas or '',
}
def _consumo_to_dict(self, c):
return {
'id': c.id,
'fecha': c.fecha.strftime('%Y-%m-%d %H:%M') if c.fecha else None,
'cita_id': c.cita_id.id if c.cita_id else None,
'venta_id': c.venta_id.id if c.venta_id else None,
'servicio_id': c.servicio_id.id if c.servicio_id else None,
'servicio': c.servicio_id.name if c.servicio_id else '',
'sesiones': c.sesiones,
'usuario': c.usuario or '',
'notas': c.notas or '',
'state': c.state,
}
def _membresia(self, mid):
m = request.env['skeen.membresia'].sudo().browse(int(mid))
return m if m.exists() else None
def _rango_kw(self, kw):
inicio = fields.Date.to_date(kw.get('start')) or (fields.Date.today() - timedelta(days=30))
fin = fields.Date.to_date(kw.get('end')) or fields.Date.today()
return inicio, fin
# ==================================================================
# Planes
# ==================================================================
@http.route(BASE + '/planes', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('membresias.ver')
@membresias_activas
def list_planes(self, **kw):
try:
domain = []
if not (kw.get('include_inactive') or '').strip():
domain.append(('activo', '=', True))
if kw.get('q'):
domain += ['|', ('name', 'ilike', kw['q']),
('descripcion', 'ilike', kw['q'])]
Plan = request.env['skeen.membresia.plan'].sudo()
planes = Plan.search(domain, order='sequence asc, name asc')
return _json({'status': 'success',
'planes': [self._plan_to_dict(p) for p in planes]})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/planes', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.editar')
@membresias_activas
def create_plan(self, **kw):
try:
data = _body()
nombre = (data.get('name') or '').strip()
if not nombre:
return _json({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
if request.env['skeen.membresia.plan'].sudo().search_count(
[('name', '=ilike', nombre)]):
return _json({'status': 'error',
'message': 'Ya existe un plan "%s".' % nombre}, 409)
vals = self._plan_vals(data, crear=True)
try:
plan = request.env['skeen.membresia.plan'].sudo().create(vals)
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'plan': self._plan_to_dict(plan)}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
def _plan_vals(self, data, crear=False):
vals = {}
if crear:
vals['name'] = (data.get('name') or '').strip()
if 'name' in data and not crear:
vals['name'] = (data.get('name') or '').strip()
for key in ('descripcion', 'notas'):
if key in data:
vals[key] = data.get(key) or ''
for key in ('precio', 'descuento_porcentaje', 'cargo_inscripcion'):
if key in data:
vals[key] = float(data.get(key) or 0.0)
for key in ('sesiones_incluidas', 'meses_minimos', 'sequence'):
if key in data:
vals[key] = int(data.get(key) or 0)
for key in ('permitir_congelamiento', 'activo'):
if key in data:
vals[key] = bool(data.get(key))
if 'moneda' in data:
if data['moneda'] not in ('MXN', 'USD'):
raise ValidationError('La moneda debe ser MXN o USD')
vals['moneda'] = data['moneda']
if 'periodicidad' in data:
if data['periodicidad'] not in ('semanal', 'mensual', 'trimestral',
'semestral', 'anual'):
raise ValidationError('Periodicidad inválida')
vals['periodicidad'] = data['periodicidad']
if 'servicio_id' in data:
vals['servicio_id'] = int(data['servicio_id']) if data['servicio_id'] else False
for key, model in (('servicios_incluidos', 'skeen.servicio'),
('categorias_incluidas', 'skeen.service.category'),
('sucursales', 'skeen.sucursal')):
if key in data:
ids = [int(i) for i in (data.get(key) or [])]
vals[key] = [(6, 0, ids)]
return vals
@http.route(BASE + '/planes/<int:plan_id>', type='http', auth='none',
methods=['PUT', 'PATCH'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.editar')
@membresias_activas
def update_plan(self, plan_id, **kw):
try:
plan = request.env['skeen.membresia.plan'].sudo().browse(plan_id)
if not plan.exists():
return _json({'status': 'error', 'message': 'Plan no encontrado'}, 404)
try:
vals = self._plan_vals(_body(), crear=False)
plan.write(vals)
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'plan': self._plan_to_dict(plan)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/planes/<int:plan_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.editar')
@membresias_activas
def delete_plan(self, plan_id, **kw):
try:
plan = request.env['skeen.membresia.plan'].sudo().browse(plan_id)
if not plan.exists():
return _json({'status': 'error', 'message': 'Plan no encontrado'}, 404)
if plan.membresia_ids:
return _json({
'status': 'error',
'message': 'El plan ya tiene membresías: desactívalo en lugar '
'de borrarlo.',
}, 409)
plan.unlink()
return _json({'status': 'success', 'message': 'Plan eliminado'})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
# ==================================================================
# Membresías
# ==================================================================
@http.route(BASE, type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('membresias.ver')
@membresias_activas
def list_membresias(self, **kw):
try:
domain = []
if kw.get('estado'):
if kw['estado'] not in ESTADOS:
return _json({'status': 'error', 'message': 'Estado inválido'}, 400)
domain.append(('estado', '=', kw['estado']))
if kw.get('plan_id'):
domain.append(('plan_id', '=', int(kw['plan_id'])))
if kw.get('partner_id'):
domain.append(('partner_id', '=', int(kw['partner_id'])))
if (kw.get('por_vencer') or '').strip() in ('1', 'true', 'True'):
ventanas = _cfg().get('dias_aviso_renovacion') or [7]
limite = fields.Date.today() + timedelta(days=max(ventanas))
domain += [('estado', 'in', ('activa', 'congelada')),
('proximo_cobro', '!=', False),
('proximo_cobro', '<=', limite)]
if kw.get('q'):
q = kw['q']
domain += ['|', '|', ('folio', 'ilike', q),
('partner_id.name', 'ilike', q),
('plan_id.name', 'ilike', q)]
Membresia = request.env['skeen.membresia'].sudo()
total = Membresia.search_count(domain)
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
rows = Membresia.search(domain, order='create_date desc, id desc',
limit=page_size, offset=(page - 1) * page_size)
return _json({
'status': 'success',
'membresias': [self._membresia_to_dict(m) for m in rows],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
'resumen': self._resumen(),
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
def _resumen(self):
Membresia = request.env['skeen.membresia'].sudo()
hoy = fields.Date.today()
ventanas = _cfg().get('dias_aviso_renovacion') or [7]
limite = hoy + timedelta(days=max(ventanas))
activas = Membresia.search([('estado', 'in', ('activa', 'congelada'))])
por_vencer = activas.filtered(
lambda m: m.proximo_cobro and hoy <= m.proximo_cobro <= limite)
vencidas = Membresia.search_count([('estado', '=', 'vencida')])
mrr = 0.0
for m in activas:
if m.plan_id:
mrr += (m.plan_id.precio or 0.0) * MESES_POR_PERIODICIDAD.get(
m.plan_id.periodicidad, 1.0)
return {
'activas': len(activas),
'por_vencer': len(por_vencer),
'vencidas': vencidas,
'mrr_estimado': round(mrr, 2),
}
@http.route(BASE + '/por_paciente', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('membresias.ver')
@membresias_activas
def membresias_por_paciente(self, **kw):
try:
partner_id = int(kw.get('partner_id') or 0)
if not partner_id:
return _json({'status': 'error', 'message': 'Falta partner_id'}, 400)
rows = request.env['skeen.membresia'].sudo().search([
('partner_id', '=', partner_id),
('estado', '=', 'activa'),
], order='create_date desc')
return _json({'status': 'success',
'membresias': [self._membresia_to_dict(m) for m in rows]})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('membresias.ver')
@membresias_activas
def membrecia_detalle(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
return _json({
'status': 'success',
'membresia': self._membresia_to_dict(m),
'periodos': [self._periodo_to_dict(p)
for p in m.periodo_ids.sorted('periodo_inicio')],
'consumos': [self._consumo_to_dict(c) for c in m.consumo_ids],
'ventas': [{
'id': v.id, 'name': v.name,
'date': v.date.strftime('%Y-%m-%d') if v.date else None,
'total': v.total, 'state': v.state,
'payment_method': v.payment_method or '',
} for v in m.venta_ids.sorted('date')],
'saldos': {
'adeudo': m.adeudo,
'sesiones_restantes': m.sesiones_restantes,
'ilimitadas': m.ilimitadas,
'periodos_pagados': m.periodos_pagados,
},
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE, type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('membresias.crear')
@membresias_activas
def crear_membresia(self, **kw):
try:
data = _body()
partner = request.env['res.partner'].sudo().browse(
int(data.get('partner_id') or 0))
if not partner.exists():
return _json({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
plan = request.env['skeen.membresia.plan'].sudo().browse(
int(data.get('plan_id') or 0))
if not plan.exists() or not plan.activo:
return _json({'status': 'error', 'message': 'Plan no encontrado o inactivo'}, 404)
vals = {
'partner_id': partner.id,
'plan_id': plan.id,
'fecha_inicio': fields.Date.to_date(data.get('fecha_inicio'))
or fields.Date.today(),
'notas': data.get('notas') or '',
}
if data.get('renovacion_automatica') is not None:
vals['renovacion_automatica'] = bool(data['renovacion_automatica'])
if data.get('descuento_porcentaje') is not None:
vals['descuento_porcentaje'] = float(data['descuento_porcentaje'] or 0.0)
try:
m = request.env['skeen.membresia'].sudo().create(vals)
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'membresia': self._membresia_to_dict(m),
'inscripcion_cargo': plan.cargo_inscripcion}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/congelar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.cancelar')
@membresias_activas
def congelar(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
data = _body()
try:
m.action_congelar(data.get('desde'), data.get('hasta'),
data.get('motivo') or '', login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'membresia': self._membresia_to_dict(m)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/descongelar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.cancelar')
@membresias_activas
def descongelar(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
try:
m.action_descongelar(login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'membresia': self._membresia_to_dict(m)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/renovar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('membresias.cobrar')
@membresias_activas
def renovar(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
data = _body()
try:
periodo = m.action_renovar(generar_cargo=data.get('generar_cargo'),
login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'membresia': self._membresia_to_dict(m),
'periodo': self._periodo_to_dict(periodo) if periodo else None})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/cancelar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('membresias.cancelar')
@membresias_activas
def cancelar(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
data = _body()
try:
m.action_cancelar(data.get('motivo'), login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success', 'membresia': self._membresia_to_dict(m)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/cobrar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('membresias.cobrar')
@membresias_activas
def cobrar(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
data = _body()
try:
venta, pago, periodos = m.action_cobrar(
metodo=data.get('metodo') or 'cash',
monto=data.get('monto'),
moneda=data.get('moneda'),
cobrar_inscripcion=bool(data.get('cobrar_inscripcion')),
login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({
'status': 'success',
'membresia': self._membresia_to_dict(m),
'venta': {'id': venta.id, 'name': venta.name, 'total': venta.total,
'state': venta.state},
'pago': {'id': pago.id, 'name': pago.name, 'amount': pago.amount,
'payment_method': pago.payment_method},
'periodos': [self._periodo_to_dict(p) for p in periodos],
}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(BASE + '/<int:mid>/consumir', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('membresias.cobrar')
@membresias_activas
def consumir(self, mid, **kw):
try:
m = self._membresia(mid)
if not m:
return _json({'status': 'error', 'message': 'Membresía no encontrada'}, 404)
data = _body()
try:
consumo = m.action_consumir(
sesiones=data.get('sesiones') or 1,
cita_id=data.get('cita_id') or False,
venta_id=data.get('venta_id') or False,
servicio_id=data.get('servicio_id') or False,
notas=data.get('notas') or '',
login=_login())
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success',
'consumo': self._consumo_to_dict(consumo),
'membresia': self._membresia_to_dict(m)}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
# ==================================================================
# Reporte de membresías
# ==================================================================
@http.route(BASE + '/reporte', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('membresias.ver')
@membresias_activas
def reporte(self, **kw):
try:
inicio, fin = self._rango_kw(kw)
Membresia = request.env['skeen.membresia'].sudo()
altas = Membresia.search_count([
('fecha_inicio', '>=', inicio), ('fecha_inicio', '<=', fin)])
cancelaciones = Membresia.search_count([
('estado', '=', 'cancelada'),
('fecha_cancelacion', '>=', datetime.combine(inicio, datetime.min.time())),
('fecha_cancelacion', '<=', datetime.combine(fin, datetime.max.time())),
])
periodos = request.env['skeen.membresia.periodo'].sudo().search([
('estado', '=', 'pagado'),
('fecha_pago', '>=', datetime.combine(inicio, datetime.min.time())),
('fecha_pago', '<=', datetime.combine(fin, datetime.max.time())),
])
por_mes = defaultdict(float)
recurrentes = defaultdict(float)
for p in periodos:
clave = p.fecha_pago.strftime('%Y-%m')
por_mes[clave] += p.monto
if p.tipo == 'periodo':
recurrentes[clave] += p.monto
ingresos = [{'mes': k, 'total': round(v, 2)}
for k, v in sorted(por_mes.items())]
recurrentes_mes = [{'mes': k, 'total': round(v, 2)}
for k, v in sorted(recurrentes.items())]
hoy = fields.Date.today()
proximas = Membresia.search([
('estado', 'in', ('activa', 'congelada')),
('proximo_cobro', '>=', hoy),
('proximo_cobro', '<=', hoy + timedelta(days=60)),
], order='proximo_cobro asc', limit=50)
consumos = request.env['skeen.membresia.consumo'].sudo().search([
('state', '=', 'consumed'),
('fecha', '>=', datetime.combine(inicio, datetime.min.time())),
('fecha', '<=', datetime.combine(fin, datetime.max.time())),
])
activas = Membresia.search([('estado', 'in', ('activa', 'congelada'))])
incluidas = sum((m.sesiones_incluidas_periodo or 0) for m in activas
if not m.ilimitadas)
return _json({
'status': 'success',
'start': inicio.strftime('%Y-%m-%d'),
'end': fin.strftime('%Y-%m-%d'),
'altas': altas,
'cancelaciones': cancelaciones,
'ingresos_por_mes': ingresos,
'recurrentes_por_mes': recurrentes_mes,
'total_ingresos': round(sum(por_mes.values()), 2),
'total_recurrente': round(sum(recurrentes.values()), 2),
'proximas_renovaciones': [
{'id': m.id, 'folio': m.folio, 'patient': m.partner_id.name,
'plan': m.plan_id.name, 'proximo_cobro':
m.proximo_cobro.strftime('%Y-%m-%d') if m.proximo_cobro else None,
'precio': m.plan_id.precio, 'moneda': m.plan_id.moneda}
for m in proximas],
'sesiones_consumidas': sum(c.sesiones for c in consumos),
'sesiones_incluidas_activas': incluidas,
'membresias_activas': len(activas),
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Motor de membresías. Con `membresias.activo=False` (default) el
método regresa sin tocar nada: cero cargos, cero avisos. Corre a
diario: marca vencidas (respetando dias_gracia), encola avisos de
renovación (idempotentes por periodo), genera el cargo del nuevo
periodo para membresías de renovación automática y descongela las
membresías cuyo plazo de congelamiento terminó. -->
<record id="cron_membresias" model="ir.cron">
<field name="name">SKEEN Membresías: vencimientos, avisos y cargos recurrentes</field>
<field name="model_id" ref="model_skeen_membresia_service"/>
<field name="state">code</field>
<field name="code">model._cron_tick()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Folio de membresía: MEM-0001, MEM-0002, ... -->
<record id="seq_skeen_membresia" model="ir.sequence">
<field name="name">Membresía SKEEN</field>
<field name="code">skeen.membresia</field>
<field name="prefix">MEM-</field>
<field name="padding">4</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import membresia
from . import venta

View File

@@ -0,0 +1,806 @@
# -*- coding: utf-8 -*-
"""Membresías y suscripciones.
Una MEMBRESÍA no es un paquete. El paquete (`skeen.paquete.compra`) es prepago
de N sesiones de un servicio a precio cerrado. La membresía es un plan
recurrente con vigencia (semanal..anual), sesiones incluidas por periodo, cobro
recurrente, congelamiento y renovación.
Modelos:
skeen.membresia.plan catálogo de planes
skeen.membresia la suscripción de un paciente (folio MEM-)
skeen.membresia.periodo cada periodo cobrado / por cobrar (ingreso recurrente)
skeen.membresia.consumo cada sesión usada (cita o venta)
Regla de oro: con `membresias.activo=False` (default de la instancia) el cron
no hace nada y no se genera ningún cargo ni aviso.
"""
import logging
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
from markupsafe import Markup
from odoo.addons.skeen_citas.clinic_settings import get_membresias
_logger = logging.getLogger(__name__)
PERIODICIDADES = [
('semanal', 'Semanal'),
('mensual', 'Mensual'),
('trimestral', 'Trimestral'),
('semestral', 'Semestral'),
('anual', 'Anual'),
]
# Duración de cada periodo. Se usa relativedelta para que los meses naturales
# coincidan (un mes desde el 31 de enero cae el 28/29 de febrero).
PERIODO_DELTA = {
'semanal': relativedelta(weeks=1),
'mensual': relativedelta(months=1),
'trimestral': relativedelta(months=3),
'semestral': relativedelta(months=6),
'anual': relativedelta(years=1),
}
ESTADOS_MEMBRESIA = [
('activa', 'Activa'),
('congelada', 'Congelada'),
('vencida', 'Vencida'),
('pausada', 'Pausada'),
('cancelada', 'Cancelada'),
]
ESTADOS_PERIODO = [
('pendiente', 'Pendiente'),
('pagado', 'Pagado'),
('vencido', 'Vencido'),
('condonado', 'Condonado'),
]
MONEDAS = [('MXN', 'MXN'), ('USD', 'USD')]
def _delta_periodo(periodicidad):
return PERIODO_DELTA.get(periodicidad or 'mensual', PERIODO_DELTA['mensual'])
def _money(v):
return '$%s' % format(round(float(v or 0.0), 2), ',.2f')
class SkeenMembresiaPlan(models.Model):
_name = 'skeen.membresia.plan'
_description = 'Plan de Membresía'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True)
descripcion = fields.Text(string='Descripción')
precio = fields.Float(string='Precio por periodo', required=True, default=0.0)
moneda = fields.Selection(MONEDAS, string='Moneda', default='MXN', required=True)
periodicidad = fields.Selection(PERIODICIDADES, string='Periodicidad',
default='mensual', required=True)
sesiones_incluidas = fields.Integer(
string='Sesiones incluidas', default=0,
help='Sesiones incluidas por periodo. 0 = ilimitadas.')
servicios_incluidos = fields.Many2many(
'skeen.servicio', 'skeen_mem_plan_servicio_rel', 'plan_id', 'servicio_id',
string='Servicios incluidos')
categorias_incluidas = fields.Many2many(
'skeen.service.category', 'skeen_mem_plan_categoria_rel', 'plan_id',
'categoria_id', string='Categorías incluidas',
help='Los servicios de estas categorías cuentan como incluidos.')
descuento_porcentaje = fields.Float(
string='Descuento en servicios no incluidos (%)', default=0.0)
cargo_inscripcion = fields.Float(string='Cargo de inscripción', default=0.0)
meses_minimos = fields.Integer(string='Meses mínimos', default=0,
help='Compromiso mínimo. 0 = sin mínimo.')
permitir_congelamiento = fields.Boolean(string='Permite congelamiento', default=True)
activo = fields.Boolean(string='Activo', default=True)
sequence = fields.Integer(string='Secuencia', default=10)
sucursales = fields.Many2many(
'skeen.sucursal', 'skeen_mem_plan_sucursal_rel', 'plan_id', 'sucursal_id',
string='Sucursales')
servicio_id = fields.Many2one(
'skeen.servicio', string='Servicio de cobro',
help='Servicio con el que se registra la venta del plan (los cortes y '
'reportes lo usan). Si se deja vacío se toma el primer servicio '
'incluido.')
notas = fields.Text(string='Notas')
membresia_ids = fields.One2many('skeen.membresia', 'plan_id', string='Membresías')
membresias_count = fields.Integer(string='Membresías activas',
compute='_compute_membresias_count')
_sql_constraints = [
('name_uniq', 'unique(name)', 'Ya existe un plan con ese nombre.'),
]
@api.depends('membresia_ids.estado')
def _compute_membresias_count(self):
for plan in self:
plan.membresias_count = len(
plan.membresia_ids.filtered(lambda m: m.estado in ('activa', 'congelada')))
@api.constrains('precio', 'sesiones_incluidas', 'descuento_porcentaje',
'cargo_inscripcion', 'meses_minimos')
def _check_positivos(self):
for plan in self:
if plan.precio < 0:
raise ValidationError(_('El precio del plan no puede ser negativo.'))
if plan.sesiones_incluidas < 0:
raise ValidationError(_('Las sesiones incluidas no pueden ser negativas.'))
if not (0 <= plan.descuento_porcentaje <= 100):
raise ValidationError(_('El descuento debe estar entre 0 y 100.'))
if plan.cargo_inscripcion < 0:
raise ValidationError(_('El cargo de inscripción no puede ser negativo.'))
if plan.meses_minimos < 0:
raise ValidationError(_('Los meses mínimos no pueden ser negativos.'))
def servicio_cobro(self):
"""Servicio con el que se factura el plan (o None).
Orden: `servicio_id` -> primer servicio incluido -> None. La venta de
una membresía necesita un servicio en la línea (regla de skeen.venta.line).
"""
self.ensure_one()
return self.servicio_id or self.servicios_incluidos[:1]
def incluye_servicio(self, servicio):
"""True si el servicio está incluido en el plan.
Se considera incluido si está en `servicios_incluidos` o si su categoría
(`skeen.servicio.category`, que es un código) coincide con el código de
alguna `categorias_incluidas`. Sin ninguna configurada, NO incluye nada
(plan solo de descuento).
"""
self.ensure_one()
if not servicio:
return False
if servicio in self.servicios_incluidos:
return True
codigos = set(self.categorias_incluidas.mapped('code'))
return bool(codigos and servicio.category in codigos)
def name_get(self):
return [(p.id, p.name) for p in self]
def unlink(self):
"""No se borra un plan con membresías: se desactiva."""
for plan in self:
if self.env['skeen.membresia'].sudo().search_count([('plan_id', '=', plan.id)]):
raise ValidationError(_(
'El plan "%s" ya tiene membresías y no se puede borrar. '
'Desactívalo para dejar de ofrecerlo.', plan.name))
return super().unlink()
class SkeenMembresia(models.Model):
_name = 'skeen.membresia'
_description = 'Membresía de Paciente'
_rec_name = 'folio'
_order = 'create_date desc, id desc'
_inherit = ['mail.thread']
folio = fields.Char(string='Folio', readonly=True, copy=False, index=True)
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
domain=[('is_patient', '=', True)], index=True)
partner_phone = fields.Char(related='partner_id.phone', string='Teléfono',
readonly=True)
plan_id = fields.Many2one('skeen.membresia.plan', string='Plan', required=True,
ondelete='restrict')
estado = fields.Selection(ESTADOS_MEMBRESIA, string='Estado', default='activa',
required=True, tracking=True, index=True)
fecha_inicio = fields.Date(string='Inicio', required=True,
default=fields.Date.today)
fecha_fin = fields.Date(string='Fin compromiso')
periodo_actual_inicio = fields.Date(string='Periodo actual · inicio')
periodo_actual_fin = fields.Date(string='Periodo actual · fin')
proximo_cobro = fields.Date(string='Próximo cobro', index=True)
sesiones_incluidas_periodo = fields.Integer(string='Sesiones incluidas / periodo',
default=0)
sesiones_usadas_periodo = fields.Integer(string='Sesiones usadas / periodo',
default=0)
renovacion_automatica = fields.Boolean(string='Renovación automática', default=False)
descuento_porcentaje = fields.Float(string='Descuento en no incluidos (%)', default=0.0)
congelada_desde = fields.Date(string='Congelada desde')
congelada_hasta = fields.Date(string='Congelada hasta')
dias_congelados_anio = fields.Integer(string='Días congelados (año)', default=0)
motivo_cancelacion = fields.Text(string='Motivo de cancelación')
fecha_cancelacion = fields.Datetime(string='Cancelada el', readonly=True, copy=False)
notas = fields.Text(string='Notas')
venta_ids = fields.One2many('skeen.venta', 'membresia_id', string='Ventas')
periodo_ids = fields.One2many('skeen.membresia.periodo', 'membresia_id',
string='Periodos')
consumo_ids = fields.One2many('skeen.membresia.consumo', 'membresia_id',
string='Consumos')
# Derivados
ilimitadas = fields.Boolean(string='Sesiones ilimitadas',
compute='_compute_sesiones', store=True)
sesiones_restantes = fields.Integer(string='Sesiones restantes',
compute='_compute_sesiones', store=True)
adeudo = fields.Float(string='Adeudo', compute='_compute_adeudo', store=True)
periodos_pagados = fields.Integer(string='Periodos pagados',
compute='_compute_adeudo', store=True)
vigente = fields.Boolean(string='Vigente', compute='_compute_vigente', store=True)
dias_para_cobro = fields.Integer(string='Días para el cobro',
compute='_compute_vigente', store=True)
_sql_constraints = [
('folio_uniq', 'unique(folio)', 'El folio de la membresía debe ser único.'),
]
# ------------------------------------------------------------------
# Cómputos
# ------------------------------------------------------------------
@api.depends('sesiones_incluidas_periodo', 'sesiones_usadas_periodo')
def _compute_sesiones(self):
for m in self:
m.ilimitadas = (m.sesiones_incluidas_periodo or 0) <= 0
if m.ilimitadas:
m.sesiones_restantes = 0
else:
m.sesiones_restantes = max(
0, m.sesiones_incluidas_periodo - m.sesiones_usadas_periodo)
@api.depends('periodo_ids.estado', 'periodo_ids.monto')
def _compute_adeudo(self):
for m in self:
m.adeudo = sum(p.monto for p in m.periodo_ids
if p.estado in ('pendiente', 'vencido'))
m.periodos_pagados = len(
m.periodo_ids.filtered(lambda p: p.estado == 'pagado'))
@api.depends('estado', 'proximo_cobro')
def _compute_vigente(self):
hoy = fields.Date.today()
for m in self:
m.vigente = m.estado in ('activa', 'congelada')
m.dias_para_cobro = ((m.proximo_cobro - hoy).days
if m.proximo_cobro else 0)
# ------------------------------------------------------------------
# Alta
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('folio'):
# Se busca la secuencia por código y se usa next_by_id (no
# next_by_code): en peticiones auth='none' `env.company` puede
# no ser la compañía de la secuencia y next_by_code devolvería
# False.
seq = self.env['ir.sequence'].sudo().search(
[('code', '=', 'skeen.membresia')], limit=1)
folio = seq.next_by_id() if seq else False
if not folio:
raise ValidationError(_(
'No se pudo generar el folio de la membresía '
'(secuencia skeen.membresia).'))
vals['folio'] = folio
plan = self.env['skeen.membresia.plan'].browse(vals.get('plan_id'))
if plan.exists():
cfg = get_membresias(self.env)
vals.setdefault('sesiones_incluidas_periodo', plan.sesiones_incluidas)
vals.setdefault('descuento_porcentaje',
plan.descuento_porcentaje or cfg.get('descuento_porcentaje_default') or 0.0)
vals.setdefault('renovacion_automatica',
bool(cfg.get('renovacion_automatica_default')))
inicio = fields.Date.to_date(vals.get('fecha_inicio')) or fields.Date.today()
delta = _delta_periodo(plan.periodicidad)
vals.setdefault('periodo_actual_inicio', inicio)
vals.setdefault('periodo_actual_fin', inicio + delta - relativedelta(days=1))
vals.setdefault('proximo_cobro', inicio + delta)
if plan.meses_minimos and not vals.get('fecha_fin'):
vals['fecha_fin'] = inicio + relativedelta(months=plan.meses_minimos)
return super().create(vals_list)
# ------------------------------------------------------------------
# Acciones
# ------------------------------------------------------------------
def _usuario(self, login=''):
return (login or '').strip()
def _post(self, body):
"""Publica en el chatter con un autor válido.
Los controladores del frontend usan auth='none': en la petición no hay
usuario de Odoo y `message_post` falla con "Expected singleton". Se
publica como el administrador y el login real va en el cuerpo.
"""
author = self.env.ref('base.user_admin', raise_if_not_found=False)
if not author:
author = self.env['res.users'].sudo().search([('login', '=', 'admin')], limit=1)
(self.with_user(author) if author else self).message_post(body=body)
def action_congelar(self, desde, hasta, motivo='', login=''):
"""Congela la membresía respetando el límite anual configurado."""
self.ensure_one()
cfg = get_membresias(self.env)
if not cfg.get('permitir_congelamiento') or not self.plan_id.permitir_congelamiento:
raise ValidationError(_('Este plan no permite congelamiento.'))
if self.estado != 'activa':
raise ValidationError(_('Solo se puede congelar una membresía activa.'))
desde = fields.Date.to_date(desde)
hasta = fields.Date.to_date(hasta)
if not desde or not hasta or hasta < desde:
raise ValidationError(_('El rango de congelamiento no es válido.'))
dias = (hasta - desde).days + 1
tope = int(cfg.get('dias_max_congelamiento_anio') or 0)
if tope and (self.dias_congelados_anio + dias) > tope:
restantes = max(0, tope - self.dias_congelados_anio)
raise ValidationError(_(
'El límite de congelamiento anual es de %(tope)s días; solo te '
'quedan %(restantes)s.',
) % {'tope': tope, 'restantes': restantes})
vals = {
'estado': 'congelada',
'congelada_desde': desde,
'congelada_hasta': hasta,
'dias_congelados_anio': self.dias_congelados_anio + dias,
}
# Se corren el fin de periodo y el próximo cobro para no perder días.
if self.periodo_actual_fin:
vals['periodo_actual_fin'] = self.periodo_actual_fin + relativedelta(days=dias)
if self.proximo_cobro:
vals['proximo_cobro'] = self.proximo_cobro + relativedelta(days=dias)
self.write(vals)
self._post(body=Markup(
'<b>Membresía congelada</b><br/>Del %s al %s (%s días).<br/>Motivo: %s<br/>Por: %s'
) % (desde, hasta, dias, motivo or 'sin motivo', login or 'sistema'))
return True
def action_descongelar(self, login=''):
self.ensure_one()
if self.estado != 'congelada':
raise ValidationError(_('La membresía no está congelada.'))
self.write({'estado': 'activa', 'congelada_desde': False,
'congelada_hasta': False})
self._post(body=Markup('<b>Membresía reactivada</b><br/>Por: %s')
% (login or 'sistema'))
return True
def action_cancelar(self, motivo, login=''):
self.ensure_one()
motivo = (motivo or '').strip()
if not motivo:
raise ValidationError(_('El motivo de cancelación es obligatorio.'))
if self.estado == 'cancelada':
raise ValidationError(_('La membresía ya está cancelada.'))
self.write({'estado': 'cancelada', 'motivo_cancelacion': motivo,
'fecha_cancelacion': fields.Datetime.now()})
self._post(body=Markup(
'<b>Membresía cancelada</b><br/>Motivo: %s<br/>Por: %s'
) % (motivo, login or 'sistema'))
return True
def action_renovar(self, generar_cargo=None, login=''):
"""Avanza el periodo actual y, si toca, genera el cargo del siguiente."""
self.ensure_one()
if self.estado == 'cancelada':
raise ValidationError(_('No se puede renovar una membresía cancelada.'))
cfg = get_membresias(self.env)
delta = _delta_periodo(self.plan_id.periodicidad)
if self.proximo_cobro:
inicio = self.proximo_cobro
elif self.periodo_actual_fin:
inicio = self.periodo_actual_fin + relativedelta(days=1)
else:
inicio = fields.Date.today()
self.write({
'estado': 'activa',
'periodo_actual_inicio': inicio,
'periodo_actual_fin': inicio + delta - relativedelta(days=1),
'proximo_cobro': inicio + delta,
'sesiones_usadas_periodo': 0,
})
if generar_cargo is None:
generar_cargo = bool(cfg.get('generar_cargo_al_renovar'))
periodo = None
if generar_cargo:
periodo = self._crear_periodo_pendiente(self.periodo_actual_inicio,
self.periodo_actual_fin)
self._post(body=Markup(
'<b>Membresía renovada</b><br/>Periodo %s a %s.<br/>Cargo generado: %s<br/>Por: %s'
) % (self.periodo_actual_inicio, self.periodo_actual_fin,
'sí' if periodo else 'no', login or 'sistema'))
return periodo
def _servicio_requerido(self):
"""Servicio de cobro del plan o error claro.
La línea de venta EXIGE un servicio o un artículo (regla de
skeen.venta.line), así que un plan sin servicio de cobro ni servicios
incluidos no se puede facturar."""
self.ensure_one()
servicio = self.plan_id.servicio_cobro()
if not servicio:
raise ValidationError(_(
'El plan "%s" no tiene servicio de cobro ni servicios incluidos: '
'asigna uno para poder cobrar la membresía.',
) % self.plan_id.name)
return servicio
def _crear_periodo_pendiente(self, inicio, fin):
"""Crea el periodo y la venta-adeudo del periodo (sin pago)."""
self.ensure_one()
existente = self.periodo_ids.filtered(
lambda p: p.periodo_inicio == inicio and p.tipo == 'periodo'
and p.estado in ('pendiente', 'pagado'))[:1]
if existente:
return existente
servicio = self._servicio_requerido()
venta = self.env['skeen.venta'].sudo().create({
'partner_id': self.partner_id.id,
'membresia_id': self.id,
'date': fields.Date.today(),
'notes': _('Periodo de membresía %s (%s a %s)') % (
self.plan_id.name, inicio, fin),
'line_ids': [(0, 0, {
'service_id': servicio.id,
'description': _('Membresía %s · %s a %s') % (
self.plan_id.name, inicio, fin),
'quantity': 1.0,
'price_unit': self.plan_id.precio,
})],
})
venta.action_confirm()
return self.env['skeen.membresia.periodo'].sudo().create({
'membresia_id': self.id,
'tipo': 'periodo',
'periodo_inicio': inicio,
'periodo_fin': fin,
'monto': self.plan_id.precio,
'moneda': self.plan_id.moneda,
'venta_id': venta.id,
'estado': 'pendiente',
})
def action_cobrar(self, metodo='cash', monto=None, moneda=None,
cobrar_inscripcion=False, login=''):
"""Cobra el periodo actual registrando venta + pago normales.
Devuelve (venta, pago, lista_de_periodos). El corte de caja lo ve igual
que cualquier otra venta porque el pago es un `skeen.pago` completado.
"""
self.ensure_one()
if self.estado == 'cancelada':
raise ValidationError(_('No se puede cobrar una membresía cancelada.'))
if metodo not in ('cash', 'card', 'transfer', 'stripe', 'mercadopago',
'saldo_favor'):
raise ValidationError(_('Método de pago inválido.'))
moneda = moneda or self.plan_id.moneda or 'MXN'
if moneda not in ('MXN', 'USD'):
raise ValidationError(_('Moneda no soportada (MXN/USD).'))
lineas = []
periodos_payload = []
servicio = self._servicio_requerido()
# Periodo recurrente
inicio = self.periodo_actual_inicio or self.fecha_inicio
fin = self.periodo_actual_fin or (inicio + _delta_periodo(
self.plan_id.periodicidad) - relativedelta(days=1))
monto_periodo = float(monto) if monto not in (None, '') else self.plan_id.precio
lineas.append((0, 0, {
'service_id': servicio.id,
'description': _('Membresía %s · %s a %s') % (self.plan_id.name, inicio, fin),
'quantity': 1.0,
'price_unit': monto_periodo,
}))
periodos_payload.append({
'tipo': 'periodo', 'periodo_inicio': inicio, 'periodo_fin': fin,
'monto': monto_periodo,
})
# Inscripción (solo la primera vez / cuando se pide)
if cobrar_inscripcion and self.plan_id.cargo_inscripcion:
ya = self.periodo_ids.filtered(lambda p: p.tipo == 'inscripcion')
if not ya:
lineas.append((0, 0, {
'service_id': servicio.id,
'description': _('Inscripción %s') % self.plan_id.name,
'quantity': 1.0,
'price_unit': self.plan_id.cargo_inscripcion,
}))
periodos_payload.append({
'tipo': 'inscripcion', 'periodo_inicio': inicio,
'periodo_fin': inicio, 'monto': self.plan_id.cargo_inscripcion,
})
venta = self.env['skeen.venta'].sudo().create({
'partner_id': self.partner_id.id,
'membresia_id': self.id,
'date': fields.Date.today(),
'user_login': self._usuario(login),
'notes': _('Cobro de membresía %s') % self.plan_id.name,
'line_ids': lineas,
})
venta.action_confirm()
total = venta.total
currency = self.env.ref('base.USD' if moneda == 'USD' else 'base.MXN')
pago = self.env['skeen.pago'].sudo().create({
'partner_id': self.partner_id.id,
'venta_id': venta.id,
'amount': total,
'currency_id': currency.id,
'payment_method': metodo,
'state': 'completed',
'payment_date': fields.Datetime.now(),
})
venta.write({'amount_paid': total})
venta.action_mark_paid()
# Periodos: se liquidan los que ya existían para ese rango y, si no, se crea.
periodos = self.env['skeen.membresia.periodo'].sudo()
for payload in periodos_payload:
existente = self.periodo_ids.filtered(
lambda p, pl=payload: p.tipo == pl['tipo']
and p.periodo_inicio == pl['periodo_inicio'])[:1]
if existente:
existente.write({'estado': 'pagado', 'monto': payload['monto'],
'venta_id': venta.id})
periodos |= existente
else:
periodos |= self.env['skeen.membresia.periodo'].sudo().create({
'membresia_id': self.id,
'venta_id': venta.id,
'moneda': moneda,
'estado': 'pagado',
**payload,
})
self._post(body=Markup(
'<b>Periodo cobrado</b><br/>Venta %s por %s (%s) · método %s.<br/>Por: %s'
) % (venta.name, _money(total), moneda, metodo, login or 'sistema'))
return venta, pago, periodos
def action_consumir(self, sesiones=1, cita_id=None, venta_id=None,
servicio_id=None, notas='', validar_servicio=True, login=''):
"""Registra el consumo de sesiones de la membresía.
Por diseño NO se descuenta en silencio: el POS/cita debe llamar a esta
acción explícitamente ("usar sesión de membresía").
"""
self.ensure_one()
if self.estado != 'activa':
raise ValidationError(_('La membresía no está activa.'))
try:
sesiones = int(sesiones or 1)
except (TypeError, ValueError):
raise ValidationError(_('El número de sesiones no es válido.'))
if sesiones <= 0:
raise ValidationError(_('Las sesiones a consumir deben ser mayores a cero.'))
if not self.ilimitadas and self.sesiones_restantes < sesiones:
raise ValidationError(_(
'Sin sesiones restantes: quedan %s de %s en el periodo.',
) % (self.sesiones_restantes, self.sesiones_incluidas_periodo))
servicio = self.env['skeen.servicio'].browse(servicio_id) if servicio_id else None
if validar_servicio and servicio and not self.plan_id.incluye_servicio(servicio):
raise ValidationError(_(
'El servicio "%s" no está incluido en el plan %s.',
) % (servicio.name, self.plan_id.name))
consumo = self.env['skeen.membresia.consumo'].sudo().create({
'membresia_id': self.id,
'cita_id': cita_id or False,
'venta_id': venta_id or False,
'servicio_id': servicio.id if servicio else False,
'sesiones': sesiones,
'usuario': self._usuario(login),
'notas': notas or '',
})
self.sesiones_usadas_periodo += sesiones
return consumo
# ------------------------------------------------------------------
# Reversión del consumo (al cancelar una cita que usó una sesión)
# ------------------------------------------------------------------
def action_liberar_consumo(self, consumo):
"""Libera un consumo (devuelve la sesión al periodo)."""
self.ensure_one()
consumo = consumo.exists()
if not consumo or consumo.membresia_id != self or consumo.state != 'consumed':
return False
consumo.write({'state': 'cancelled'})
self.sesiones_usadas_periodo = max(0, self.sesiones_usadas_periodo - consumo.sesiones)
return True
class SkeenMembresiaPeriodo(models.Model):
_name = 'skeen.membresia.periodo'
_description = 'Periodo de Membresía'
_order = 'periodo_inicio desc, id desc'
membresia_id = fields.Many2one('skeen.membresia', string='Membresía',
required=True, ondelete='cascade', index=True)
partner_id = fields.Many2one(related='membresia_id.partner_id', string='Paciente',
store=True, readonly=True)
tipo = fields.Selection([
('periodo', 'Periodo'),
('inscripcion', 'Inscripción'),
('ajuste', 'Ajuste'),
], string='Tipo', default='periodo', required=True)
periodo_inicio = fields.Date(string='Inicio', required=True)
periodo_fin = fields.Date(string='Fin', required=True)
monto = fields.Float(string='Monto', required=True, default=0.0)
moneda = fields.Selection(MONEDAS, string='Moneda', default='MXN')
venta_id = fields.Many2one('skeen.venta', string='Venta', ondelete='set null')
estado = fields.Selection(ESTADOS_PERIODO, string='Estado', default='pendiente',
required=True, index=True)
fecha_pago = fields.Datetime(string='Pagado el')
notas = fields.Char(string='Notas')
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('estado') == 'pagado' and not vals.get('fecha_pago'):
vals['fecha_pago'] = fields.Datetime.now()
return super().create(vals_list)
def write(self, vals):
if vals.get('estado') == 'pagado' and not vals.get('fecha_pago'):
vals = dict(vals, fecha_pago=fields.Datetime.now())
return super().write(vals)
class SkeenMembresiaConsumo(models.Model):
_name = 'skeen.membresia.consumo'
_description = 'Consumo de Sesión de Membresía'
_order = 'fecha desc, id desc'
membresia_id = fields.Many2one('skeen.membresia', string='Membresía',
required=True, ondelete='cascade', index=True)
partner_id = fields.Many2one(related='membresia_id.partner_id', string='Paciente',
store=True, readonly=True)
fecha = fields.Datetime(string='Fecha', default=fields.Datetime.now, required=True)
cita_id = fields.Many2one('skeen.cita', string='Cita', ondelete='set null')
venta_id = fields.Many2one('skeen.venta', string='Venta', ondelete='set null')
servicio_id = fields.Many2one('skeen.servicio', string='Servicio')
sesiones = fields.Integer(string='Sesiones', default=1, required=True)
usuario = fields.Char(string='Usuario')
notas = fields.Text(string='Notas')
state = fields.Selection([
('consumed', 'Consumida'),
('cancelled', 'Liberada'),
], string='Estado', default='consumed')
class SkeenMembresiaService(models.AbstractModel):
"""Motor de membresías: configuración, vencimientos y cron diario."""
_name = 'skeen.membresia.service'
_description = 'Servicio de membresías de SKEEN'
@api.model
def _cfg(self):
return get_membresias(self.env)
@api.model
def activo(self):
return bool(self._cfg().get('activo'))
# ------------------------------------------------------------------
# Cron diario
# ------------------------------------------------------------------
@api.model
def _cron_tick(self):
"""Corre la lógica diaria. Con `membresias.activo=False` no hace nada."""
resultado = {'vencidas': 0, 'avisos': 0, 'cargos': 0, 'descongeladas': 0}
cfg = self._cfg()
if not cfg.get('activo'):
return resultado
resultado['descongeladas'] = self._aplicar_limite_congelamiento(cfg)
resultado['vencidas'] = self._marcar_vencidas(cfg)
resultado['avisos'] = self._encolar_avisos(cfg)
resultado['cargos'] = self._generar_cargos(cfg)
if any(resultado.values()):
_logger.info('SKEEN membresías: %s', resultado)
return resultado
@api.model
def _marcar_vencidas(self, cfg):
"""Marca vencidas las membresías con el periodo vencido + días de gracia."""
hoy = fields.Date.today()
gracia = int(cfg.get('dias_gracia') or 0)
Membresia = self.env['skeen.membresia'].sudo()
candidatas = Membresia.search([('estado', 'in', ('activa', 'pausada'))])
total = 0
for m in candidatas:
fin = m.periodo_actual_fin or m.fecha_fin
if not fin:
continue
if fin + relativedelta(days=gracia) < hoy:
# Si hay un adeudo del periodo, queda vencida; si no hay cargos
# pendientes (p. ej. ilimitada sin cobro) también vence.
m.write({'estado': 'vencida'})
m.periodo_ids.filtered(
lambda p: p.estado == 'pendiente' and p.periodo_fin <= fin
).write({'estado': 'vencido'})
total += 1
return total
@api.model
def _encolar_avisos(self, cfg):
"""Encola avisos de renovación en cada ventana configurada.
Idempotencia por `clave_unica = mem:<id>:<proximo_cobro>:<dias>`, así el
cron diario no repite el mismo aviso.
"""
Notif = self.env['skeen.notificacion.service'].sudo()
Membresia = self.env['skeen.membresia'].sudo()
hoy = fields.Date.today()
ventanas = [int(d) for d in (cfg.get('dias_aviso_renovacion') or [])]
total = 0
for m in Membresia.search([('estado', 'in', ('activa', 'congelada'))]):
if not m.proximo_cobro:
continue
faltan = (m.proximo_cobro - hoy).days
for dias in ventanas:
if faltan != dias:
continue
clave = 'mem:%s:%s:%s' % (m.id, m.proximo_cobro, dias)
if self.env['skeen.notificacion'].sudo().search_count(
[('clave_unica', 'like', clave + '%')]):
continue
creadas = Notif.encolar(
partner=m.partner_id,
tipo='renovacion_membresia',
cuando=fields.Datetime.now(),
ventana='%s:%s' % (m.proximo_cobro, dias),
clave_unica=clave,
contexto_extra={
'plan': m.plan_id.name,
'membresia': m.folio or '',
'monto': _money(m.plan_id.precio),
'sesiones': (str(m.sesiones_incluidas_periodo)
if m.sesiones_incluidas_periodo else 'ilimitadas'),
'periodo': '%s a %s' % (m.periodo_actual_inicio or '',
m.periodo_actual_fin or ''),
'fecha': m.proximo_cobro.strftime('%d/%m/%Y')
if m.proximo_cobro else '',
})
total += len(creadas)
return total
@api.model
def _generar_cargos(self, cfg):
"""Genera el adeudo del nuevo periodo para membresías de renovación automática."""
if not cfg.get('generar_cargo_al_renovar'):
return 0
Membresia = self.env['skeen.membresia'].sudo()
hoy = fields.Date.today()
total = 0
for m in Membresia.search([('estado', '=', 'activa'),
('renovacion_automatica', '=', True)]):
if not m.proximo_cobro or m.proximo_cobro > hoy:
continue
delta = _delta_periodo(m.plan_id.periodicidad)
inicio = m.proximo_cobro
fin = inicio + delta - relativedelta(days=1)
periodo = m._crear_periodo_pendiente(inicio, fin)
# Avanza el periodo para no repetir el cargo en la siguiente corrida.
m.write({
'periodo_actual_inicio': inicio,
'periodo_actual_fin': fin,
'proximo_cobro': fin + relativedelta(days=1),
'sesiones_usadas_periodo': 0,
})
if periodo:
total += 1
return total
@api.model
def _aplicar_limite_congelamiento(self, cfg):
"""Descongela las membresías cuya ventana de congelamiento ya terminó."""
hoy = fields.Date.today()
Membresia = self.env['skeen.membresia'].sudo()
total = 0
for m in Membresia.search([('estado', '=', 'congelada')]):
if m.congelada_hasta and m.congelada_hasta < hoy:
m.write({'estado': 'activa', 'congelada_desde': False,
'congelada_hasta': False})
total += 1
return total

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
"""Enlace de las ventas con la membresía que las generó.
El campo vive en skeen.venta (módulo de ventas) para que `skeen.membresia` pueda
declarar su One2many sin crear un ciclo de dependencias. Sirve para el detalle
de la membresía (ventas ligadas) y para los reportes de ingreso recurrente.
"""
from odoo import fields, models
class SkeenVentaMembresia(models.Model):
_inherit = 'skeen.venta'
membresia_id = fields.Many2one('skeen.membresia', string='Membresía',
ondelete='set null', index=True,
help='Membresía cuyo periodo/inscripción generó '
'esta venta.')

View File

@@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_membresia_plan_user,skeen.membresia.plan.user,model_skeen_membresia_plan,base.group_user,1,1,1,1
access_membresia_user,skeen.membresia.user,model_skeen_membresia,base.group_user,1,1,1,1
access_membresia_periodo_user,skeen.membresia.periodo.user,model_skeen_membresia_periodo,base.group_user,1,1,1,1
access_membresia_consumo_user,skeen.membresia.consumo.user,model_skeen_membresia_consumo,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_membresia_plan_user skeen.membresia.plan.user model_skeen_membresia_plan base.group_user 1 1 1 1
3 access_membresia_user skeen.membresia.user model_skeen_membresia base.group_user 1 1 1 1
4 access_membresia_periodo_user skeen.membresia.periodo.user model_skeen_membresia_periodo base.group_user 1 1 1 1
5 access_membresia_consumo_user skeen.membresia.consumo.user model_skeen_membresia_consumo base.group_user 1 1 1 1

View File

@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ Planes ============ -->
<record id="view_membresia_plan_tree" model="ir.ui.view">
<field name="name">skeen.membresia.plan.tree</field>
<field name="model">skeen.membresia.plan</field>
<field name="arch" type="xml">
<tree string="Planes de membresía">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="periodicidad"/>
<field name="precio"/>
<field name="moneda"/>
<field name="sesiones_incluidas"/>
<field name="membresias_count"/>
<field name="activo"/>
</tree>
</field>
</record>
<record id="view_membresia_plan_form" model="ir.ui.view">
<field name="name">skeen.membresia.plan.form</field>
<field name="model">skeen.membresia.plan</field>
<field name="arch" type="xml">
<form string="Plan de membresía">
<sheet>
<group>
<group>
<field name="name"/>
<field name="periodicidad"/>
<field name="precio"/>
<field name="moneda"/>
<field name="sesiones_incluidas"/>
<field name="servicio_id"/>
</group>
<group>
<field name="cargo_inscripcion"/>
<field name="descuento_porcentaje"/>
<field name="meses_minimos"/>
<field name="permitir_congelamiento"/>
<field name="activo"/>
<field name="sequence"/>
</group>
</group>
<group string="Incluye">
<field name="servicios_incluidos" widget="many2many_tags"/>
<field name="categorias_incluidas" widget="many2many_tags"/>
<field name="sucursales" widget="many2many_tags"/>
</group>
<group>
<field name="descripcion"/>
<field name="notas"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ============ Membresías ============ -->
<record id="view_membresia_tree" model="ir.ui.view">
<field name="name">skeen.membresia.tree</field>
<field name="model">skeen.membresia</field>
<field name="arch" type="xml">
<tree string="Membresías">
<field name="folio"/>
<field name="partner_id"/>
<field name="plan_id"/>
<field name="estado"/>
<field name="fecha_inicio"/>
<field name="proximo_cobro"/>
<field name="sesiones_restantes"/>
<field name="adeudo"/>
</tree>
</field>
</record>
<record id="view_membresia_form" model="ir.ui.view">
<field name="name">skeen.membresia.form</field>
<field name="model">skeen.membresia</field>
<field name="arch" type="xml">
<form string="Membresía">
<sheet>
<group>
<group>
<field name="folio"/>
<field name="partner_id"/>
<field name="plan_id"/>
<field name="estado"/>
<field name="fecha_inicio"/>
<field name="fecha_fin"/>
</group>
<group>
<field name="periodo_actual_inicio"/>
<field name="periodo_actual_fin"/>
<field name="proximo_cobro"/>
<field name="sesiones_incluidas_periodo"/>
<field name="sesiones_usadas_periodo"/>
<field name="renovacion_automatica"/>
<field name="descuento_porcentaje"/>
</group>
</group>
<group string="Congelamiento">
<field name="congelada_desde"/>
<field name="congelada_hasta"/>
<field name="dias_congelados_anio"/>
</group>
<notebook>
<page string="Periodos">
<field name="periodo_ids">
<tree editable="bottom">
<field name="tipo"/>
<field name="periodo_inicio"/>
<field name="periodo_fin"/>
<field name="monto"/>
<field name="moneda"/>
<field name="estado"/>
<field name="venta_id"/>
<field name="fecha_pago"/>
</tree>
</field>
</page>
<page string="Consumos">
<field name="consumo_ids">
<tree>
<field name="fecha"/>
<field name="servicio_id"/>
<field name="sesiones"/>
<field name="cita_id"/>
<field name="venta_id"/>
<field name="state"/>
</tree>
</field>
</page>
<page string="Ventas">
<field name="venta_ids">
<tree>
<field name="name"/>
<field name="date"/>
<field name="total"/>
<field name="state"/>
</tree>
</field>
</page>
</notebook>
<group>
<field name="motivo_cancelacion"/>
<field name="notas"/>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
</odoo>

View File

@@ -44,6 +44,7 @@ TIPOS = [
('post_visita', 'Post-visita'), ('post_visita', 'Post-visita'),
('recuperacion', 'Recuperación'), ('recuperacion', 'Recuperación'),
('formulario', 'Formulario'), ('formulario', 'Formulario'),
('renovacion_membresia', 'Renovación de membresía'),
('manual', 'Manual'), ('manual', 'Manual'),
] ]
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')] CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
@@ -67,6 +68,8 @@ PLANTILLA_POR_TIPO = {
'recuperacion': 'plantilla_recordatorio', 'recuperacion': 'plantilla_recordatorio',
# El aviso de un formulario digital usa su propia plantilla con {enlace}. # El aviso de un formulario digital usa su propia plantilla con {enlace}.
'formulario': 'plantilla_formulario', 'formulario': 'plantilla_formulario',
# Aviso de renovación/cobro de una membresía (bloque `membresias`).
'renovacion_membresia': 'plantilla_renovacion_membresia',
'manual': 'plantilla_recordatorio', 'manual': 'plantilla_recordatorio',
} }

View File

@@ -95,6 +95,13 @@ PERMISSION_GROUPS = [
('formularios.editar', 'Administrar plantillas de formularios'), ('formularios.editar', 'Administrar plantillas de formularios'),
('formularios.enviar', 'Enviar formularios a pacientes'), ('formularios.enviar', 'Enviar formularios a pacientes'),
]), ]),
('membresias', 'Membresías y suscripciones', [
('membresias.ver', 'Ver membresías, planes y adeudos'),
('membresias.crear', 'Dar de alta membresías'),
('membresias.cobrar', 'Cobrar y renovar membresías'),
('membresias.editar', 'Editar planes y membresías'),
('membresias.cancelar', 'Congelar y cancelar membresías'),
]),
('config', 'Usuarios y configuración', [ ('config', 'Usuarios y configuración', [
('config.usuarios', 'Administrar usuarios'), ('config.usuarios', 'Administrar usuarios'),
('config.roles', 'Administrar roles y menús'), ('config.roles', 'Administrar roles y menús'),
@@ -123,6 +130,7 @@ READ_PERM_KEYS = (
'whatsapp.ver', 'whatsapp.ver',
'notificaciones.ver', 'notificaciones.ver',
'formularios.ver', 'formularios.ver',
'membresias.ver',
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -210,6 +218,14 @@ DEFAULT_PERM_ROLES = {
'formularios.crear': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'), 'formularios.crear': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
'formularios.editar': AG, 'formularios.editar': AG,
'formularios.enviar': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'), 'formularios.enviar': ('admin', 'medico', 'recepcion', 'enfermeria', 'gerencia'),
# Membresías y suscripciones. Ver es de todos; dar de alta y cobrar es de
# recepción/gerencia/admin; editar planes y cancelar/congelar es de
# gerencia/admin (contra el dinero recurrente de la clínica).
'membresias.ver': ALL,
'membresias.crear': ('admin', 'recepcion', 'gerencia'),
'membresias.cobrar': ('admin', 'recepcion', 'gerencia'),
'membresias.editar': AG,
'membresias.cancelar': AG,
# Usuarios y configuración # Usuarios y configuración
'config.usuarios': AG, 'config.usuarios': AG,
'config.roles': AG, 'config.roles': AG,

View File

@@ -160,6 +160,9 @@ DEFAULT_MENU_ROLES = {
'/medicos': ['admin', 'recepcion', 'gerencia'], '/medicos': ['admin', 'recepcion', 'gerencia'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'], '/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'], '/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
# Membresías: apagado por defecto (membresias.activo=false). La sección solo
# se pinta cuando la instancia la enciende; aquí queda su visibilidad por rol.
'/membresias': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], '/productos': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/ventas': ['admin', 'recepcion', 'gerencia'], '/ventas': ['admin', 'recepcion', 'gerencia'],
'/tickets': ['admin', 'gerencia'], '/tickets': ['admin', 'gerencia'],
@@ -172,6 +175,7 @@ DEFAULT_MENU_ROLES = {
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=paquetes': ['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=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],