fase 2.6: membresias y suscripciones (modulo skeen_membresias: planes recurrentes con sesiones incluidas o ilimitadas, alta/cobro/renovacion/congelamiento/cancelacion, consumo de sesiones desde POS y expediente, cron de vencimientos y avisos, reporte de ingresos recurrentes); apagado por defecto
This commit is contained in:
@@ -10,6 +10,7 @@ import { ServiceCategoriesProvider } from './lib/service-categories';
|
|||||||
import { QuickLinksProvider } from './lib/quicklinks';
|
import { 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>} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
223
frontend/src/components/MembresiasPaciente.tsx
Normal file
223
frontend/src/components/MembresiasPaciente.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Repeat, Plus } from 'lucide-react';
|
||||||
|
import { Button, Input, Select, Modal, Badge, toast } from './ui';
|
||||||
|
import { odooApi, type Membresia, type MembresiaPlan } from '../services/odoo';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
import { useGlosario } from '../lib/glosario';
|
||||||
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
|
|
||||||
|
// Bloque "Membresías" del expediente del paciente: membresías activas, saldo de
|
||||||
|
// sesiones por periodo y acciones (alta, usar sesión, cobrar, renovar). Solo se
|
||||||
|
// usa cuando la clínica tiene `membresias.activo=true`.
|
||||||
|
|
||||||
|
const fmtMoney = (n: number) =>
|
||||||
|
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
const ESTADO_CLS: Record<string, string> = {
|
||||||
|
activa: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||||
|
congelada: 'bg-sky-100 text-sky-700 border-sky-200',
|
||||||
|
vencida: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||||
|
pausada: 'bg-theme-bg text-theme-muted border-theme-border',
|
||||||
|
cancelada: 'bg-rose-100 text-rose-700 border-rose-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
partnerId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MembresiasPaciente: FC<Props> = ({ partnerId }) => {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { term } = useGlosario();
|
||||||
|
const { membresias: cfg } = useClinicSettings();
|
||||||
|
const [membresias, setMembresias] = useState<Membresia[]>([]);
|
||||||
|
const [planes, setPlanes] = useState<MembresiaPlan[]>([]);
|
||||||
|
const [cargando, setCargando] = useState(false);
|
||||||
|
const [nuevaOpen, setNuevaOpen] = useState(false);
|
||||||
|
const [nueva, setNueva] = useState({ plan_id: '', fecha_inicio: '', renovacion_automatica: false, descuento: '' });
|
||||||
|
const [guardando, setGuardando] = useState(false);
|
||||||
|
const [consumo, setConsumo] = useState<{ m: Membresia; servicio_id: string } | null>(null);
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
if (!partnerId) return;
|
||||||
|
try {
|
||||||
|
setCargando(true);
|
||||||
|
const res = await odooApi.getMembresiasPorPaciente(partnerId);
|
||||||
|
if (res.status === 'success') setMembresias(res.membresias);
|
||||||
|
} catch {
|
||||||
|
/* silencioso: el bloque es informativo */
|
||||||
|
} finally {
|
||||||
|
setCargando(false);
|
||||||
|
}
|
||||||
|
}, [partnerId]);
|
||||||
|
|
||||||
|
useEffect(() => { cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
const abrirNueva = async () => {
|
||||||
|
if (planes.length === 0) {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getMembresiaPlanes();
|
||||||
|
if (res.status === 'success') setPlanes(res.planes);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
setNueva({ plan_id: '', fecha_inicio: new Date().toISOString().slice(0, 10), renovacion_automatica: cfg.renovacion_automatica_default, descuento: '' });
|
||||||
|
setNuevaOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const crear = async () => {
|
||||||
|
if (!nueva.plan_id) { toast.error('Selecciona un plan'); return; }
|
||||||
|
try {
|
||||||
|
setGuardando(true);
|
||||||
|
const res = await odooApi.crearMembresia({
|
||||||
|
partner_id: partnerId,
|
||||||
|
plan_id: parseInt(nueva.plan_id, 10),
|
||||||
|
fecha_inicio: nueva.fecha_inicio || undefined,
|
||||||
|
renovacion_automatica: nueva.renovacion_automatica,
|
||||||
|
...(nueva.descuento !== '' ? { descuento_porcentaje: parseFloat(nueva.descuento) } : {}),
|
||||||
|
});
|
||||||
|
if (res.status !== 'success') { toast.error(res.message || 'No se pudo crear'); return; }
|
||||||
|
toast.success('Membresía creada');
|
||||||
|
setNuevaOpen(false);
|
||||||
|
cargar();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al crear la membresía');
|
||||||
|
} finally {
|
||||||
|
setGuardando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const usarSesion = async () => {
|
||||||
|
if (!consumo || !consumo.servicio_id) { toast.error('Selecciona el servicio'); return; }
|
||||||
|
try {
|
||||||
|
const res = await odooApi.consumirMembresia(consumo.m.id, {
|
||||||
|
sesiones: 1, servicio_id: parseInt(consumo.servicio_id, 10),
|
||||||
|
notas: 'Consumido desde el expediente',
|
||||||
|
});
|
||||||
|
if (res.status !== 'success') { toast.error(res.message || 'No se pudo consumir'); return; }
|
||||||
|
toast.success('Sesión de membresía registrada');
|
||||||
|
setConsumo(null);
|
||||||
|
cargar();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al consumir la sesión');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cfg.activo) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h4 className="text-sm font-semibold text-theme-heading flex items-center gap-2">
|
||||||
|
<Repeat size={15} /> Membresías
|
||||||
|
</h4>
|
||||||
|
{can('membresias.crear') && (
|
||||||
|
<Button variant="outline" size="sm" onClick={abrirNueva}>
|
||||||
|
<Plus size={13} className="mr-1" /> Nueva
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cargando ? (
|
||||||
|
<p className="text-xs text-theme-muted">Cargando…</p>
|
||||||
|
) : membresias.length === 0 ? (
|
||||||
|
<p className="text-xs text-theme-muted">Sin membresías activas.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{membresias.map((m) => (
|
||||||
|
<li key={m.id} className="rounded-xl border border-theme-border p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-theme-heading truncate">{m.plan}</p>
|
||||||
|
<p className="text-[11px] text-theme-muted">
|
||||||
|
{m.folio} · {m.periodo_actual_inicio} a {m.periodo_actual_fin}
|
||||||
|
{m.proximo_cobro ? ` · próximo cobro ${m.proximo_cobro}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-block text-[11px] font-medium px-2 py-0.5 rounded-full border ${ESTADO_CLS[m.estado] || ESTADO_CLS.pausada}`}>
|
||||||
|
{m.estado_label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-theme-muted">
|
||||||
|
{m.ilimitadas
|
||||||
|
? 'Sesiones ilimitadas'
|
||||||
|
: `${m.sesiones_usadas_periodo}/${m.sesiones_incluidas_periodo} sesiones · quedan ${m.sesiones_restantes}`}
|
||||||
|
{m.adeudo > 0 && <span className="text-amber-700 font-medium"> · adeudo {fmtMoney(m.adeudo)}</span>}
|
||||||
|
</p>
|
||||||
|
{m.estado === 'activa' && can('membresias.cobrar') && !m.ilimitadas && m.sesiones_restantes > 0 && m.servicios_incluidos.length > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setConsumo({ m, servicio_id: String(m.servicios_incluidos[0]?.id || '') })}>
|
||||||
|
Usar sesión
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal isOpen={nuevaOpen} onClose={() => setNuevaOpen(false)} title={`Nueva membresía para el ${term('patient_singular').toLowerCase()}`}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Plan *</label>
|
||||||
|
<Select
|
||||||
|
options={[{ value: '', label: 'Selecciona un plan…' },
|
||||||
|
...planes.map((p) => ({ value: String(p.id), label: `${p.name} · ${fmtMoney(p.precio)} / ${p.periodicidad}` }))]}
|
||||||
|
value={nueva.plan_id} onChange={(e) => setNueva({ ...nueva, plan_id: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Inicio</label>
|
||||||
|
<Input type="date" value={nueva.fecha_inicio} onChange={(e) => setNueva({ ...nueva, fecha_inicio: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Descuento (%)</label>
|
||||||
|
<Input type="number" min={0} max={100} value={nueva.descuento}
|
||||||
|
onChange={(e) => setNueva({ ...nueva, descuento: e.target.value })} placeholder="Por defecto del plan" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-theme-heading">
|
||||||
|
<input type="checkbox" checked={nueva.renovacion_automatica}
|
||||||
|
onChange={(e) => setNueva({ ...nueva, renovacion_automatica: e.target.checked })}
|
||||||
|
className="rounded border-theme-border-strong" />
|
||||||
|
Renovación automática
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setNuevaOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={crear} disabled={guardando}>{guardando ? 'Guardando…' : 'Crear membresía'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={!!consumo} onClose={() => setConsumo(null)} title="Usar sesión de membresía">
|
||||||
|
{consumo && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-theme-muted">
|
||||||
|
Se descontará 1 sesión del periodo. {consumo.m.ilimitadas ? 'El plan es ilimitado.' : `Quedan ${consumo.m.sesiones_restantes}.`}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Servicio</label>
|
||||||
|
<Select
|
||||||
|
options={consumo.m.servicios_incluidos.map((s) => ({ value: String(s.id), label: s.name }))}
|
||||||
|
value={consumo.servicio_id}
|
||||||
|
onChange={(e) => setConsumo({ ...consumo, servicio_id: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setConsumo(null)}>Cancelar</Button>
|
||||||
|
<Button onClick={usarSesion}>Registrar consumo</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{membresias.some((m) => m.estado !== 'activa') && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<Badge variant="warning">Hay membresías no activas; revísalas en el módulo Membresías.</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MembresiasPaciente;
|
||||||
456
frontend/src/components/MembresiasPanel.tsx
Normal file
456
frontend/src/components/MembresiasPanel.tsx
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Repeat, Plus, Pencil, Trash2, Save } from 'lucide-react';
|
||||||
|
import { Button, Input, TextArea, Select, Modal, toast } from './ui';
|
||||||
|
import {
|
||||||
|
odooApi, MEMBRESIAS_DEFAULT,
|
||||||
|
type ClinicSettings, type MembresiasSettings, type MembresiaPlan,
|
||||||
|
type Service, type ServiceCategory, type PeriodicidadMembresia,
|
||||||
|
} from '../services/odoo';
|
||||||
|
|
||||||
|
// Configuración → Membresías: ajustes del bloque + CRUD de planes.
|
||||||
|
// Mientras `activo` esté apagado, la sección no aparece en el menú y el POS no
|
||||||
|
// cambia; este panel es la llave para encender el módulo.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: ClinicSettings;
|
||||||
|
onSaved: (settings: ClinicSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERIODICIDADES: { value: PeriodicidadMembresia; label: string }[] = [
|
||||||
|
{ value: 'semanal', label: 'Semanal' },
|
||||||
|
{ value: 'mensual', label: 'Mensual' },
|
||||||
|
{ value: 'trimestral', label: 'Trimestral' },
|
||||||
|
{ value: 'semestral', label: 'Semestral' },
|
||||||
|
{ value: 'anual', label: 'Anual' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FormPlan {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
periodicidad: PeriodicidadMembresia;
|
||||||
|
precio: string;
|
||||||
|
moneda: 'MXN' | 'USD';
|
||||||
|
sesiones_incluidas: string;
|
||||||
|
descuento_porcentaje: string;
|
||||||
|
cargo_inscripcion: string;
|
||||||
|
meses_minimos: string;
|
||||||
|
permitir_congelamiento: boolean;
|
||||||
|
activo: boolean;
|
||||||
|
servicio_id: string;
|
||||||
|
servicios_incluidos: number[];
|
||||||
|
categorias_incluidas: number[];
|
||||||
|
descripcion: string;
|
||||||
|
notas: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: FormPlan = {
|
||||||
|
name: '', periodicidad: 'mensual', precio: '', moneda: 'MXN',
|
||||||
|
sesiones_incluidas: '0', descuento_porcentaje: '0', cargo_inscripcion: '0',
|
||||||
|
meses_minimos: '0', permitir_congelamiento: true, activo: true,
|
||||||
|
servicio_id: '', servicios_incluidos: [], categorias_incluidas: [],
|
||||||
|
descripcion: '', notas: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-theme-heading">{label}</span>
|
||||||
|
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||||
|
const [form, setForm] = useState<MembresiasSettings>(settings.membresias || MEMBRESIAS_DEFAULT);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [avisos, setAvisos] = useState((settings.membresias?.dias_aviso_renovacion || [7, 1]).join(', '));
|
||||||
|
|
||||||
|
const [planes, setPlanes] = useState<MembresiaPlan[]>([]);
|
||||||
|
const [servicios, setServicios] = useState<Service[]>([]);
|
||||||
|
const [categorias, setCategorias] = useState<ServiceCategory[]>([]);
|
||||||
|
const [cargandoPlanes, setCargandoPlanes] = useState(false);
|
||||||
|
const [mostrarBaja, setMostrarBaja] = useState(false);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [planForm, setPlanForm] = useState<FormPlan>(emptyForm);
|
||||||
|
const [guardandoPlan, setGuardandoPlan] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setForm(settings.membresias || MEMBRESIAS_DEFAULT);
|
||||||
|
setAvisos((settings.membresias?.dias_aviso_renovacion || [7, 1]).join(', '));
|
||||||
|
}, [settings.membresias]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
odooApi.getServices({ page_size: 500 })
|
||||||
|
.then((res) => { if (res.status === 'success') setServicios(res.services); })
|
||||||
|
.catch(() => {});
|
||||||
|
odooApi.getServiceCategories()
|
||||||
|
.then((res) => { if (res.status === 'success') setCategorias(res.categories); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPlanes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setCargandoPlanes(true);
|
||||||
|
const res = await odooApi.getMembresiaPlanes({ includeInactive: mostrarBaja });
|
||||||
|
if (res.status === 'success') setPlanes(res.planes);
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar los planes');
|
||||||
|
} finally {
|
||||||
|
setCargandoPlanes(false);
|
||||||
|
}
|
||||||
|
}, [mostrarBaja]);
|
||||||
|
|
||||||
|
useEffect(() => { loadPlanes(); }, [loadPlanes]);
|
||||||
|
|
||||||
|
const set = <K extends keyof MembresiasSettings>(k: K, v: MembresiasSettings[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, [k]: v }));
|
||||||
|
|
||||||
|
const guardarConfig = async () => {
|
||||||
|
const dias = avisos.split(',').map((d) => parseInt(d.trim(), 10)).filter((d) => !Number.isNaN(d));
|
||||||
|
if (dias.length === 0) { toast.error('Indica al menos un día de aviso (ej. 7, 1)'); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.putClinicSettings({
|
||||||
|
membresias: { ...form, dias_aviso_renovacion: dias },
|
||||||
|
motivo: 'Configuración de membresías',
|
||||||
|
});
|
||||||
|
if (res.status === 'success') {
|
||||||
|
onSaved(res.settings);
|
||||||
|
toast.success('Configuración de membresías guardada');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'No se pudo guardar la configuración');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const abrirNuevo = () => { setPlanForm(emptyForm); setFormOpen(true); };
|
||||||
|
const abrirEditar = (p: MembresiaPlan) => {
|
||||||
|
setPlanForm({
|
||||||
|
id: p.id, name: p.name, periodicidad: p.periodicidad, precio: String(p.precio),
|
||||||
|
moneda: p.moneda, sesiones_incluidas: String(p.sesiones_incluidas),
|
||||||
|
descuento_porcentaje: String(p.descuento_porcentaje),
|
||||||
|
cargo_inscripcion: String(p.cargo_inscripcion), meses_minimos: String(p.meses_minimos),
|
||||||
|
permitir_congelamiento: p.permitir_congelamiento, activo: p.activo,
|
||||||
|
servicio_id: p.servicio_id ? String(p.servicio_id) : '',
|
||||||
|
servicios_incluidos: p.servicios_incluidos.map((s) => s.id),
|
||||||
|
categorias_incluidas: p.categorias_incluidas.map((c) => c.id),
|
||||||
|
descripcion: p.descripcion || '', notas: p.notas || '',
|
||||||
|
});
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const guardarPlan = async () => {
|
||||||
|
if (!planForm.name.trim()) { toast.error('El nombre del plan es obligatorio'); return; }
|
||||||
|
const payload = {
|
||||||
|
name: planForm.name.trim(),
|
||||||
|
periodicidad: planForm.periodicidad,
|
||||||
|
precio: parseFloat(planForm.precio) || 0,
|
||||||
|
moneda: planForm.moneda,
|
||||||
|
sesiones_incluidas: parseInt(planForm.sesiones_incluidas, 10) || 0,
|
||||||
|
descuento_porcentaje: parseFloat(planForm.descuento_porcentaje) || 0,
|
||||||
|
cargo_inscripcion: parseFloat(planForm.cargo_inscripcion) || 0,
|
||||||
|
meses_minimos: parseInt(planForm.meses_minimos, 10) || 0,
|
||||||
|
permitir_congelamiento: planForm.permitir_congelamiento,
|
||||||
|
activo: planForm.activo,
|
||||||
|
servicio_id: planForm.servicio_id ? parseInt(planForm.servicio_id, 10) : null,
|
||||||
|
servicios_incluidos: planForm.servicios_incluidos,
|
||||||
|
categorias_incluidas: planForm.categorias_incluidas,
|
||||||
|
descripcion: planForm.descripcion.trim(),
|
||||||
|
notas: planForm.notas.trim(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
setGuardandoPlan(true);
|
||||||
|
const res = planForm.id
|
||||||
|
? await odooApi.updateMembresiaPlan(planForm.id, payload)
|
||||||
|
: await odooApi.createMembresiaPlan(payload);
|
||||||
|
if (res.status !== 'success') { toast.error(res.message || 'No se pudo guardar el plan'); return; }
|
||||||
|
toast.success(planForm.id ? 'Plan actualizado' : 'Plan creado');
|
||||||
|
setFormOpen(false);
|
||||||
|
loadPlanes();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al guardar el plan');
|
||||||
|
} finally {
|
||||||
|
setGuardandoPlan(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cambiarActivo = async (p: MembresiaPlan) => {
|
||||||
|
const texto = p.activo
|
||||||
|
? `¿Desactivar "${p.name}"? Dejará de ofrecerse para nuevas membresías.`
|
||||||
|
: `¿Reactivar "${p.name}"?`;
|
||||||
|
if (!window.confirm(texto)) return;
|
||||||
|
try {
|
||||||
|
const res = await odooApi.updateMembresiaPlan(p.id, { activo: !p.activo });
|
||||||
|
if (res.status !== 'success') { toast.error(res.message || 'No se pudo cambiar'); return; }
|
||||||
|
loadPlanes();
|
||||||
|
} catch { toast.error('Error al cambiar el estado'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const borrarPlan = async (p: MembresiaPlan) => {
|
||||||
|
if (!window.confirm(`¿Borrar el plan "${p.name}"? Solo se puede si no tiene membresías.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await odooApi.deleteMembresiaPlan(p.id);
|
||||||
|
if (res.status !== 'success') { toast.error(res.message || 'No se pudo borrar'); return; }
|
||||||
|
toast.success('Plan eliminado');
|
||||||
|
loadPlanes();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al borrar el plan');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleId = (list: number[], id: number) =>
|
||||||
|
list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Repeat size={20} className="mt-0.5 shrink-0 text-theme-accent" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-xl text-theme-heading">Membresías y suscripciones</h3>
|
||||||
|
<p className="text-sm text-theme-muted">
|
||||||
|
Planes recurrentes con vigencia, sesiones incluidas por periodo, cobro recurrente,
|
||||||
|
congelamiento y renovación. Mientras esté apagado, no aparece en el menú, el punto de
|
||||||
|
venta no cambia y no se genera ningún cargo ni aviso.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5 rounded-2xl border border-theme-border p-4">
|
||||||
|
<Toggle
|
||||||
|
label="Activar membresías"
|
||||||
|
checked={form.activo}
|
||||||
|
onChange={(v) => set('activo', v)}
|
||||||
|
ayuda="Enciende la sección Membresías, el cobro recurrente y los avisos de renovación."
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-theme-muted mb-1.5">Días de aviso de renovación</label>
|
||||||
|
<Input value={avisos} onChange={(e) => setAvisos(e.target.value)} placeholder="7, 1" />
|
||||||
|
<p className="text-[11px] text-theme-muted mt-1">Días antes del cobro en que se avisa (separados por coma).</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-theme-muted mb-1.5">Máximo de días congelados por año</label>
|
||||||
|
<Input type="number" min={0} value={form.dias_max_congelamiento_anio}
|
||||||
|
onChange={(e) => set('dias_max_congelamiento_anio', parseInt(e.target.value, 10) || 0)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-theme-muted mb-1.5">Días de gracia antes de vencer</label>
|
||||||
|
<Input type="number" min={0} value={form.dias_gracia}
|
||||||
|
onChange={(e) => set('dias_gracia', parseInt(e.target.value, 10) || 0)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-theme-muted mb-1.5">Descuento por defecto en no incluidos (%)</label>
|
||||||
|
<Input type="number" min={0} max={100} value={form.descuento_porcentaje_default}
|
||||||
|
onChange={(e) => set('descuento_porcentaje_default', parseFloat(e.target.value) || 0)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Toggle label="Permitir congelamiento" checked={form.permitir_congelamiento}
|
||||||
|
onChange={(v) => set('permitir_congelamiento', v)} />
|
||||||
|
<Toggle label="Renovación automática por defecto" checked={form.renovacion_automatica_default}
|
||||||
|
onChange={(v) => set('renovacion_automatica_default', v)} />
|
||||||
|
<Toggle label="Generar cargo al renovar" checked={form.generar_cargo_al_renovar}
|
||||||
|
onChange={(v) => set('generar_cargo_al_renovar', v)}
|
||||||
|
ayuda="El cron crea el adeudo del nuevo periodo (no cobra)." />
|
||||||
|
<Toggle label="Permitir pausa por falta de pago" checked={form.permitir_pausa_pago}
|
||||||
|
onChange={(v) => set('permitir_pausa_pago', v)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={guardarConfig} disabled={saving}>
|
||||||
|
<Save size={16} className="mr-2" /> {saving ? 'Guardando…' : 'Guardar configuración'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h4 className="font-medium text-theme-heading">Planes</h4>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-theme-muted">
|
||||||
|
<input type="checkbox" checked={mostrarBaja} onChange={(e) => setMostrarBaja(e.target.checked)}
|
||||||
|
className="rounded border-theme-border-strong" />
|
||||||
|
Mostrar inactivos
|
||||||
|
</label>
|
||||||
|
<Button onClick={abrirNuevo}><Plus size={16} className="mr-2" /> Nuevo plan</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cargandoPlanes ? (
|
||||||
|
<p className="text-sm text-theme-muted">Cargando planes…</p>
|
||||||
|
) : planes.length === 0 ? (
|
||||||
|
<p className="text-sm text-theme-muted">Todavía no hay planes de membresía.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-theme-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">Plan</th>
|
||||||
|
<th className="text-left px-4 py-3">Periodicidad</th>
|
||||||
|
<th className="text-right px-4 py-3">Precio</th>
|
||||||
|
<th className="text-right px-4 py-3">Sesiones</th>
|
||||||
|
<th className="text-right px-4 py-3">Inscripción</th>
|
||||||
|
<th className="text-right px-4 py-3">Membresías</th>
|
||||||
|
<th className="text-left px-4 py-3">Estado</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{planes.map((p) => (
|
||||||
|
<tr key={p.id} className={!p.activo ? 'opacity-60' : ''}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="font-medium text-theme-heading">{p.name}</p>
|
||||||
|
{p.servicio && <p className="text-[11px] text-theme-muted">Cobra con: {p.servicio}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-theme-muted capitalize">{p.periodicidad}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{p.precio.toLocaleString('es-MX', { style: 'currency', currency: p.moneda, maximumFractionDigits: 2 })}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{p.sesiones_incluidas === 0 ? 'Ilimitadas' : p.sesiones_incluidas}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{p.cargo_inscripcion ? p.cargo_inscripcion.toLocaleString('es-MX', { style: 'currency', currency: p.moneda }) : '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{p.membresias_count}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button type="button" onClick={() => cambiarActivo(p)}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[11px] font-medium ${p.activo ? 'border-emerald-300 bg-emerald-50 text-emerald-800' : 'border-rose-300 bg-rose-50 text-rose-700'}`}>
|
||||||
|
{p.activo ? 'Activo' : 'Inactivo'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => abrirEditar(p)} title="Editar"><Pencil size={13} /></Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => borrarPlan(p)} title="Borrar"><Trash2 size={13} /></Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal isOpen={formOpen} onClose={() => setFormOpen(false)}
|
||||||
|
title={planForm.id ? 'Editar plan de membresía' : 'Nuevo plan de membresía'}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Nombre *</label>
|
||||||
|
<Input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })}
|
||||||
|
placeholder="Ej. Plan mensual 4 masajes" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Periodicidad</label>
|
||||||
|
<Select options={PERIODICIDADES} value={planForm.periodicidad}
|
||||||
|
onChange={(e) => setPlanForm({ ...planForm, periodicidad: e.target.value as PeriodicidadMembresia })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Moneda</label>
|
||||||
|
<Select options={[{ value: 'MXN', label: 'MXN' }, { value: 'USD', label: 'USD' }]}
|
||||||
|
value={planForm.moneda} onChange={(e) => setPlanForm({ ...planForm, moneda: e.target.value as 'MXN' | 'USD' })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Precio por periodo *</label>
|
||||||
|
<Input type="number" min={0} value={planForm.precio}
|
||||||
|
onChange={(e) => setPlanForm({ ...planForm, precio: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Sesiones incluidas (0 = ilimitadas)</label>
|
||||||
|
<Input type="number" min={0} value={planForm.sesiones_incluidas}
|
||||||
|
onChange={(e) => setPlanForm({ ...planForm, sesiones_incluidas: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Cargo de inscripción</label>
|
||||||
|
<Input type="number" min={0} value={planForm.cargo_inscripcion}
|
||||||
|
onChange={(e) => setPlanForm({ ...planForm, cargo_inscripcion: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Descuento en no incluidos (%)</label>
|
||||||
|
<Input type="number" min={0} max={100} value={planForm.descuento_porcentaje}
|
||||||
|
onChange={(e) => setPlanForm({ ...planForm, descuento_porcentaje: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Meses mínimos</label>
|
||||||
|
<Input type="number" min={0} value={planForm.meses_minimos}
|
||||||
|
onChange={(e) => setPlanForm({ ...planForm, meses_minimos: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Servicio de cobro</label>
|
||||||
|
<Select
|
||||||
|
options={[{ value: '', label: 'Primer servicio incluido' },
|
||||||
|
...servicios.map((s) => ({ value: String(s.id), label: s.name }))]}
|
||||||
|
value={planForm.servicio_id}
|
||||||
|
onChange={(e) => setPlanForm({ ...planForm, servicio_id: e.target.value })} />
|
||||||
|
<p className="text-[11px] text-theme-muted mt-1">Servicio con el que se registra la venta en caja.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Servicios incluidos</label>
|
||||||
|
<div className="max-h-40 overflow-y-auto rounded-xl border border-theme-border p-2 space-y-1">
|
||||||
|
{servicios.length === 0 && <p className="text-xs text-theme-muted px-1">No hay servicios en el catálogo.</p>}
|
||||||
|
{servicios.map((s) => (
|
||||||
|
<label key={s.id} className="flex items-center gap-2 text-sm text-theme-heading px-1">
|
||||||
|
<input type="checkbox" checked={planForm.servicios_incluidos.includes(s.id)}
|
||||||
|
onChange={() => setPlanForm({ ...planForm, servicios_incluidos: toggleId(planForm.servicios_incluidos, s.id) })}
|
||||||
|
className="rounded border-theme-border-strong" />
|
||||||
|
{s.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Categorías incluidas</label>
|
||||||
|
<div className="max-h-32 overflow-y-auto rounded-xl border border-theme-border p-2 space-y-1">
|
||||||
|
{categorias.map((c) => (
|
||||||
|
<label key={c.id} className="flex items-center gap-2 text-sm text-theme-heading px-1">
|
||||||
|
<input type="checkbox" checked={planForm.categorias_incluidas.includes(c.id)}
|
||||||
|
onChange={() => setPlanForm({ ...planForm, categorias_incluidas: toggleId(planForm.categorias_incluidas, c.id) })}
|
||||||
|
className="rounded border-theme-border-strong" />
|
||||||
|
{c.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Toggle label="Permite congelamiento" checked={planForm.permitir_congelamiento}
|
||||||
|
onChange={(v) => setPlanForm({ ...planForm, permitir_congelamiento: v })} />
|
||||||
|
<Toggle label="Activo" checked={planForm.activo}
|
||||||
|
onChange={(v) => setPlanForm({ ...planForm, activo: v })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Descripción</label>
|
||||||
|
<TextArea rows={2} value={planForm.descripcion}
|
||||||
|
onChange={(e) => setPlanForm({ ...planForm, descripcion: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Notas</label>
|
||||||
|
<TextArea rows={2} value={planForm.notas}
|
||||||
|
onChange={(e) => setPlanForm({ ...planForm, notas: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={guardarPlan} disabled={guardandoPlan}>
|
||||||
|
{guardandoPlan ? 'Guardando…' : 'Guardar plan'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MembresiasPanel;
|
||||||
@@ -9,6 +9,7 @@ import { odooApi, type Patient, type Visita, type EstadoCuenta, type PatientWall
|
|||||||
import { Badge, Button, Skeleton } from './ui';
|
import { 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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
171
frontend/src/components/reportes/MembresiasReport.tsx
Normal file
171
frontend/src/components/reportes/MembresiasReport.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Repeat, RefreshCw } from 'lucide-react';
|
||||||
|
import { Card, Button, EmptyState, Skeleton, Badge, toast } from '../ui';
|
||||||
|
import { odooApi, type MembresiasReporte } from '../../services/odoo';
|
||||||
|
import { useReportRange } from '../../lib/reportRange';
|
||||||
|
|
||||||
|
const fmtMoney = (value: number, currency = 'MXN') =>
|
||||||
|
new Intl.NumberFormat('es-MX', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value || 0);
|
||||||
|
|
||||||
|
// Pestaña "Membresías" del módulo de Reportes: altas, cancelaciones, ingresos
|
||||||
|
// recurrentes por mes, próximas renovaciones y sesiones consumidas.
|
||||||
|
const MembresiasReport: FC = () => {
|
||||||
|
const { start, end } = useReportRange('membresias');
|
||||||
|
const [data, setData] = useState<MembresiasReporte | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await odooApi.getMembresiasReporte({ start, end });
|
||||||
|
if (res.status === 'success') setData(res);
|
||||||
|
else toast.error(res.message || 'No se pudo cargar el reporte');
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al cargar el reporte de membresías');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [start, end]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<Card><Card.Body>
|
||||||
|
<EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte de membresías." icon={<Repeat size={28} />} />
|
||||||
|
</Card.Body></Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalConsumidas = data.sesiones_consumidas || 0;
|
||||||
|
const incluidas = data.sesiones_incluidas_activas || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs text-theme-muted">
|
||||||
|
Periodo {data.start} a {data.end}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={load}>
|
||||||
|
<RefreshCw size={14} className="mr-1.5" /> Actualizar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-theme-muted">Altas del periodo</p>
|
||||||
|
<p className="text-2xl font-heading text-theme-heading">{data.altas}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-theme-muted">Cancelaciones</p>
|
||||||
|
<p className="text-2xl font-heading text-theme-heading">{data.cancelaciones}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-theme-muted">Membresías activas</p>
|
||||||
|
<p className="text-2xl font-heading text-theme-heading">{data.membresias_activas}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-theme-muted">Ingresos por membresía</p>
|
||||||
|
<p className="text-2xl font-heading text-theme-heading">{fmtMoney(data.total_ingresos)}</p>
|
||||||
|
<p className="text-[11px] text-theme-muted">de ellos {fmtMoney(data.total_recurrente)} recurrentes</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Header><p className="font-medium text-theme-heading">Ingresos por mes</p></Card.Header>
|
||||||
|
<Card.Body noPadding>
|
||||||
|
{data.recurrentes_por_mes.length === 0 ? (
|
||||||
|
<div className="p-6"><p className="text-sm text-theme-muted">Sin cobros de membresía en el periodo.</p></div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">Mes</th>
|
||||||
|
<th className="text-right px-4 py-3">Ingreso recurrente</th>
|
||||||
|
<th className="text-right px-4 py-3">Total cobrado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{data.ingresos_por_mes.map((row) => {
|
||||||
|
const rec = data.recurrentes_por_mes.find((r) => r.mes === row.mes)?.total ?? 0;
|
||||||
|
return (
|
||||||
|
<tr key={row.mes}>
|
||||||
|
<td className="px-4 py-3 text-theme-heading">{row.mes}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(rec)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(row.total)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<Card.Header><p className="font-medium text-theme-heading">Próximas renovaciones (60 días)</p></Card.Header>
|
||||||
|
<Card.Body noPadding>
|
||||||
|
{data.proximas_renovaciones.length === 0 ? (
|
||||||
|
<div className="p-6"><p className="text-sm text-theme-muted">No hay renovaciones próximas.</p></div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-theme-border">
|
||||||
|
{data.proximas_renovaciones.map((r) => (
|
||||||
|
<li key={r.id} className="px-4 py-3 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-theme-heading truncate">{r.patient}</p>
|
||||||
|
<p className="text-[11px] text-theme-muted">{r.folio} · {r.plan}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-sm text-theme-heading">{r.proximo_cobro}</p>
|
||||||
|
<p className="text-[11px] text-theme-muted">{fmtMoney(r.precio, r.moneda)}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Header><p className="font-medium text-theme-heading">Sesiones</p></Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-theme-muted">Consumidas en el periodo</span>
|
||||||
|
<span className="text-theme-heading font-semibold tabular-nums">{totalConsumidas}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-theme-muted">Incluidas en membresías activas</span>
|
||||||
|
<span className="text-theme-heading font-semibold tabular-nums">{incluidas}</span>
|
||||||
|
</div>
|
||||||
|
{incluidas > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="h-2 rounded-full bg-theme-bg overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-theme-accent"
|
||||||
|
style={{ width: `${Math.min(100, Math.round((totalConsumidas / incluidas) * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-theme-muted mt-1.5">
|
||||||
|
Uso estimado del {Math.min(100, Math.round((totalConsumidas / incluidas) * 100))}% de las sesiones incluidas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Badge variant="info">Nota: comparación por periodo; estimación de uso.</Badge>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MembresiasReport;
|
||||||
@@ -37,6 +37,9 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
|||||||
'/medicos': ['admin', 'recepcion', 'gerencia'],
|
'/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'],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
611
frontend/src/pages/Membresias.tsx
Normal file
611
frontend/src/pages/Membresias.tsx
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Repeat, Plus, Search, RefreshCw, ChevronRight, CheckCircle2, AlertTriangle,
|
||||||
|
Snowflake, XCircle, Banknote,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import {
|
||||||
|
Card, Button, Input, Select, Modal, PageHeader, EmptyState, Skeleton, toast,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { odooApi } from '../services/odoo';
|
||||||
|
import type { Membresia, MembresiaDetalleResponse, MembresiaPlan, Patient } from '../services/odoo';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
import { useGlosario } from '../lib/glosario';
|
||||||
|
|
||||||
|
const fmtMoney = (n: number, currency = 'MXN') =>
|
||||||
|
(n || 0).toLocaleString('es-MX', { style: 'currency', currency, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
const ESTADO_CLS: Record<string, string> = {
|
||||||
|
activa: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||||
|
congelada: 'bg-sky-100 text-sky-700 border-sky-200',
|
||||||
|
vencida: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||||
|
pausada: 'bg-theme-bg text-theme-muted border-theme-border',
|
||||||
|
cancelada: 'bg-rose-100 text-rose-700 border-rose-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const METODOS = [
|
||||||
|
{ value: 'cash', label: 'Efectivo' },
|
||||||
|
{ value: 'card', label: 'Tarjeta' },
|
||||||
|
{ value: 'transfer', label: 'Transferencia' },
|
||||||
|
{ value: 'stripe', label: 'Stripe' },
|
||||||
|
{ value: 'mercadopago', label: 'MercadoPago' },
|
||||||
|
{ value: 'saldo_favor', label: 'Saldo a favor' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHIPS = [
|
||||||
|
{ key: '', label: 'Todas', icon: undefined as typeof CheckCircle2 | undefined },
|
||||||
|
{ key: 'activa', label: 'Activas', icon: CheckCircle2 },
|
||||||
|
{ key: 'por_vencer', label: 'Por vencer', icon: AlertTriangle },
|
||||||
|
{ key: 'vencida', label: 'Vencidas', icon: XCircle },
|
||||||
|
{ key: 'congelada', label: 'Congeladas', icon: Snowflake },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Membresias: FC = () => {
|
||||||
|
const { term } = useGlosario();
|
||||||
|
const { can } = useAuth();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [membresias, setMembresias] = useState<Membresia[]>([]);
|
||||||
|
const [resumen, setResumen] = useState({ activas: 0, por_vencer: 0, vencidas: 0, mrr_estimado: 0 });
|
||||||
|
const [planes, setPlanes] = useState<MembresiaPlan[]>([]);
|
||||||
|
const [cargando, setCargando] = useState(true);
|
||||||
|
const [filtro, setFiltro] = useState('');
|
||||||
|
const [busqueda, setBusqueda] = useState('');
|
||||||
|
const [partnerFiltro, setPartnerFiltro] = useState('');
|
||||||
|
|
||||||
|
// Detalle
|
||||||
|
const [detalle, setDetalle] = useState<MembresiaDetalleResponse | null>(null);
|
||||||
|
const [cargandoDetalle, setCargandoDetalle] = useState(false);
|
||||||
|
|
||||||
|
// Acciones
|
||||||
|
const [cobroTarget, setCobroTarget] = useState<Membresia | null>(null);
|
||||||
|
const [cobroForm, setCobroForm] = useState({ metodo: 'cash', monto: '', moneda: 'MXN', cobrar_inscripcion: false });
|
||||||
|
const [congelarTarget, setCongelarTarget] = useState<Membresia | null>(null);
|
||||||
|
const [congelarForm, setCongelarForm] = useState({ desde: '', hasta: '', motivo: '' });
|
||||||
|
const [accionando, setAccionando] = useState(false);
|
||||||
|
|
||||||
|
// Alta
|
||||||
|
const [nuevaOpen, setNuevaOpen] = useState(false);
|
||||||
|
const [pacienteQuery, setPacienteQuery] = useState('');
|
||||||
|
const [pacienteResults, setPacienteResults] = useState<Patient[]>([]);
|
||||||
|
const [paciente, setPaciente] = useState<Patient | null>(null);
|
||||||
|
const [nueva, setNueva] = useState({ plan_id: '', fecha_inicio: '', renovacion_automatica: false, descuento: '' });
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setCargando(true);
|
||||||
|
const res = await odooApi.getMembresias({
|
||||||
|
estado: filtro && filtro !== 'por_vencer' ? filtro : undefined,
|
||||||
|
por_vencer: filtro === 'por_vencer' ? true : undefined,
|
||||||
|
q: busqueda.trim() || undefined,
|
||||||
|
partner_id: partnerFiltro ? parseInt(partnerFiltro, 10) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setMembresias(res.membresias);
|
||||||
|
setResumen(res.resumen || { activas: 0, por_vencer: 0, vencidas: 0, mrr_estimado: 0 });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al cargar las membresías');
|
||||||
|
} finally {
|
||||||
|
setCargando(false);
|
||||||
|
}
|
||||||
|
}, [filtro, busqueda, partnerFiltro]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
odooApi.getMembresiaPlanes()
|
||||||
|
.then((res) => { if (res.status === 'success') setPlanes(res.planes); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filtro por paciente desde el expediente: /membresias?partner_id=X
|
||||||
|
useEffect(() => {
|
||||||
|
const pid = searchParams.get('partner_id');
|
||||||
|
if (pid) setPartnerFiltro(pid);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(load, 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paciente) return;
|
||||||
|
if (pacienteQuery.trim().length < 2) { setPacienteResults([]); return; }
|
||||||
|
let alive = true;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
odooApi.getPatients({ search: pacienteQuery.trim(), page_size: 8 })
|
||||||
|
.then((res) => { if (alive && res.status === 'success') setPacienteResults(res.patients); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, 250);
|
||||||
|
return () => { alive = false; clearTimeout(t); };
|
||||||
|
}, [pacienteQuery, paciente]);
|
||||||
|
|
||||||
|
const abrirDetalle = async (m: Membresia) => {
|
||||||
|
setCargandoDetalle(true);
|
||||||
|
setDetalle(null);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getMembresia(m.id);
|
||||||
|
if (res.status === 'success') setDetalle(res);
|
||||||
|
else toast.error(res.message || 'No se pudo abrir la membresía');
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al abrir la membresía');
|
||||||
|
} finally {
|
||||||
|
setCargandoDetalle(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refrescar = () => { load(); if (detalle) abrirDetalle(detalle.membresia); };
|
||||||
|
|
||||||
|
const abrirCobro = (m: Membresia) => {
|
||||||
|
setCobroForm({ metodo: 'cash', monto: String(m.precio), moneda: m.moneda || 'MXN', cobrar_inscripcion: false });
|
||||||
|
setCobroTarget(m);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cobrar = async () => {
|
||||||
|
if (!cobroTarget) return;
|
||||||
|
try {
|
||||||
|
setAccionando(true);
|
||||||
|
const res = await odooApi.cobrarMembresia(cobroTarget.id, {
|
||||||
|
metodo: cobroForm.metodo,
|
||||||
|
monto: cobroForm.monto ? parseFloat(cobroForm.monto) : undefined,
|
||||||
|
moneda: cobroForm.moneda,
|
||||||
|
cobrar_inscripcion: cobroForm.cobrar_inscripcion,
|
||||||
|
});
|
||||||
|
if (res.status !== 'success') { toast.error(res.message || 'No se pudo cobrar'); return; }
|
||||||
|
toast.success(`Cobrado: venta ${res.venta.name} · pago ${res.pago.name}`);
|
||||||
|
setCobroTarget(null);
|
||||||
|
setDetalle(null);
|
||||||
|
refrescar();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al cobrar');
|
||||||
|
} finally {
|
||||||
|
setAccionando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renovar = async (m: Membresia) => {
|
||||||
|
if (!window.confirm(`¿Renovar "${m.plan}" de ${m.patient}? Se avanza el periodo.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await odooApi.renovarMembresia(m.id);
|
||||||
|
if (res.status !== 'success') { toast.error(res.message || 'No se pudo renovar'); return; }
|
||||||
|
toast.success('Membresía renovada');
|
||||||
|
setDetalle(null);
|
||||||
|
refrescar();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al renovar');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const descongelar = async (m: Membresia) => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.descongelarMembresia(m.id);
|
||||||
|
if (res.status !== 'success') { toast.error(res.message || 'No se pudo descongelar'); return; }
|
||||||
|
toast.success('Membresía reactivada');
|
||||||
|
setDetalle(null);
|
||||||
|
refrescar();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al descongelar');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const congelar = async () => {
|
||||||
|
if (!congelarTarget) return;
|
||||||
|
if (!congelarForm.desde || !congelarForm.hasta) { toast.error('Indica el rango de congelamiento'); return; }
|
||||||
|
try {
|
||||||
|
setAccionando(true);
|
||||||
|
const res = await odooApi.congelarMembresia(congelarTarget.id, congelarForm);
|
||||||
|
if (res.status !== 'success') { toast.error(res.message || 'No se pudo congelar'); return; }
|
||||||
|
toast.success('Membresía congelada');
|
||||||
|
setCongelarTarget(null);
|
||||||
|
setDetalle(null);
|
||||||
|
refrescar();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al congelar');
|
||||||
|
} finally {
|
||||||
|
setAccionando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelar = async (m: Membresia) => {
|
||||||
|
const motivo = window.prompt(`¿Cancelar la membresía de ${m.patient}?\n\nMotivo (obligatorio):`);
|
||||||
|
if (!motivo || !motivo.trim()) { if (motivo !== null) toast.error('Indica el motivo'); return; }
|
||||||
|
try {
|
||||||
|
const res = await odooApi.cancelarMembresia(m.id, motivo.trim());
|
||||||
|
if (res.status !== 'success') { toast.error(res.message || 'No se pudo cancelar'); return; }
|
||||||
|
toast.success('Membresía cancelada');
|
||||||
|
setDetalle(null);
|
||||||
|
refrescar();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al cancelar');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const abrirNueva = () => {
|
||||||
|
setPaciente(null);
|
||||||
|
setPacienteQuery('');
|
||||||
|
setPacienteResults([]);
|
||||||
|
setNueva({ plan_id: '', fecha_inicio: new Date().toISOString().slice(0, 10), renovacion_automatica: false, descuento: '' });
|
||||||
|
setNuevaOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const crear = async () => {
|
||||||
|
if (!paciente) { toast.error('Selecciona el paciente'); return; }
|
||||||
|
if (!nueva.plan_id) { toast.error('Selecciona el plan'); return; }
|
||||||
|
try {
|
||||||
|
setAccionando(true);
|
||||||
|
const res = await odooApi.crearMembresia({
|
||||||
|
partner_id: paciente.id,
|
||||||
|
plan_id: parseInt(nueva.plan_id, 10),
|
||||||
|
fecha_inicio: nueva.fecha_inicio || undefined,
|
||||||
|
renovacion_automatica: nueva.renovacion_automatica,
|
||||||
|
...(nueva.descuento !== '' ? { descuento_porcentaje: parseFloat(nueva.descuento) } : {}),
|
||||||
|
});
|
||||||
|
if (res.status !== 'success') { toast.error(res.message || 'No se pudo crear'); return; }
|
||||||
|
toast.success('Membresía creada');
|
||||||
|
setNuevaOpen(false);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al crear la membresía');
|
||||||
|
} finally {
|
||||||
|
setAccionando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Membresías" subtitle="Suscripciones y cobro recurrente">
|
||||||
|
<PageHeader title="Membresías" subtitle="Planes recurrentes, sesiones incluidas, cobros y vencimientos">
|
||||||
|
<Button variant="outline" onClick={refrescar}>
|
||||||
|
<RefreshCw size={16} className="mr-2" /> Actualizar
|
||||||
|
</Button>
|
||||||
|
{can('membresias.crear') && (
|
||||||
|
<Button onClick={abrirNueva}><Plus size={16} className="mr-2" /> Nueva membresía</Button>
|
||||||
|
)}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-theme-muted">Activas</p>
|
||||||
|
<p className="text-2xl font-heading text-theme-heading">{resumen.activas}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-theme-muted">Por vencer</p>
|
||||||
|
<p className="text-2xl font-heading text-amber-700">{resumen.por_vencer}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-theme-muted">Vencidas</p>
|
||||||
|
<p className="text-2xl font-heading text-rose-700">{resumen.vencidas}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-theme-muted">MRR estimado</p>
|
||||||
|
<p className="text-2xl font-heading text-theme-heading">{fmtMoney(resumen.mrr_estimado)}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Card.Header>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{CHIPS.map((c) => (
|
||||||
|
<button key={c.key} type="button" onClick={() => setFiltro(c.key)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-full transition flex items-center gap-1 ${
|
||||||
|
filtro === c.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'}`}>
|
||||||
|
{c.icon && <c.icon size={12} />}
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 w-full max-w-md">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||||
|
<Input value={busqueda} onChange={(e) => setBusqueda(e.target.value)}
|
||||||
|
placeholder={`Buscar por ${term('patient_singular').toLowerCase()}, folio o plan`} className="pl-9" />
|
||||||
|
</div>
|
||||||
|
{partnerFiltro && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { setPartnerFiltro(''); setSearchParams({}); }}>
|
||||||
|
Quitar filtro
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Body noPadding>
|
||||||
|
{cargando ? (
|
||||||
|
<div className="p-6"><Skeleton count={6} /></div>
|
||||||
|
) : membresias.length === 0 ? (
|
||||||
|
<EmptyState title="Sin membresías" subtitle="Da de alta la primera para empezar a cobrar de forma recurrente."
|
||||||
|
icon={<Repeat size={28} />} />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">{term('patient_singular')}</th>
|
||||||
|
<th className="text-left px-4 py-3">Plan</th>
|
||||||
|
<th className="text-left px-4 py-3">Periodo</th>
|
||||||
|
<th className="text-left px-4 py-3">Próximo cobro</th>
|
||||||
|
<th className="text-left px-4 py-3">Sesiones</th>
|
||||||
|
<th className="text-right px-4 py-3">Adeudo</th>
|
||||||
|
<th className="text-left px-4 py-3">Estado</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{membresias.map((m) => (
|
||||||
|
<tr key={m.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => abrirDetalle(m)}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="font-medium text-theme-heading">{m.patient}</p>
|
||||||
|
<p className="text-[11px] text-theme-muted">{m.folio}{m.phone ? ` · ${m.phone}` : ''}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-theme-heading">{m.plan}</p>
|
||||||
|
<p className="text-[11px] text-theme-muted capitalize">{m.precio ? fmtMoney(m.precio, m.moneda) + ' / ' + m.periodicidad : m.periodicidad}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-theme-muted whitespace-nowrap">
|
||||||
|
{m.periodo_actual_inicio || '—'}<br /><span className="text-[11px]">a {m.periodo_actual_fin || '—'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap">
|
||||||
|
{m.proximo_cobro ? (
|
||||||
|
<span className={m.dias_para_cobro >= 0 && m.dias_para_cobro <= 7 ? 'text-amber-700 font-medium' : 'text-theme-muted'}>
|
||||||
|
{m.proximo_cobro}
|
||||||
|
<span className="block text-[10px]">{m.dias_para_cobro >= 0 ? `en ${m.dias_para_cobro} días` : `vencido ${-m.dias_para_cobro} d`}</span>
|
||||||
|
</span>
|
||||||
|
) : <span className="text-theme-muted">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-theme-muted whitespace-nowrap">
|
||||||
|
{m.ilimitadas ? 'Ilimitadas' : `${m.sesiones_usadas_periodo}/${m.sesiones_incluidas_periodo}`}
|
||||||
|
</td>
|
||||||
|
<td className={`px-4 py-3 text-right tabular-nums ${m.adeudo > 0 ? 'text-amber-700 font-medium' : 'text-theme-muted'}`}>
|
||||||
|
{m.adeudo > 0 ? fmtMoney(m.adeudo, m.moneda) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-block text-[11px] font-medium px-2 py-0.5 rounded-full border ${ESTADO_CLS[m.estado] || ESTADO_CLS.pausada}`}>
|
||||||
|
{m.estado_label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<ChevronRight size={16} className="inline text-theme-muted" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Detalle */}
|
||||||
|
<Modal isOpen={!!detalle || cargandoDetalle} onClose={() => setDetalle(null)}
|
||||||
|
title={detalle ? `${detalle.membresia.folio} · ${detalle.membresia.patient}` : 'Cargando…'}>
|
||||||
|
{detalle && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div><p className="text-theme-muted">Plan</p><p className="font-medium text-theme-heading">{detalle.membresia.plan}</p></div>
|
||||||
|
<div><p className="text-theme-muted">Estado</p><p className="text-theme-heading">{detalle.membresia.estado_label}</p></div>
|
||||||
|
<div><p className="text-theme-muted">Periodo actual</p><p className="text-theme-heading">{detalle.membresia.periodo_actual_inicio} a {detalle.membresia.periodo_actual_fin}</p></div>
|
||||||
|
<div><p className="text-theme-muted">Próximo cobro</p><p className="text-theme-heading">{detalle.membresia.proximo_cobro || '—'}</p></div>
|
||||||
|
<div><p className="text-theme-muted">Sesiones</p><p className="text-theme-heading">{detalle.saldos.ilimitadas ? 'Ilimitadas' : `${detalle.membresia.sesiones_usadas_periodo}/${detalle.membresia.sesiones_incluidas_periodo} · quedan ${detalle.saldos.sesiones_restantes}`}</p></div>
|
||||||
|
<div><p className="text-theme-muted">Adeudo</p><p className={detalle.saldos.adeudo > 0 ? 'text-amber-700 font-medium' : 'text-theme-heading'}>{fmtMoney(detalle.saldos.adeudo, detalle.membresia.moneda)}</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{can('membresias.cobrar') && detalle.membresia.estado !== 'cancelada' && (
|
||||||
|
<Button size="sm" onClick={() => abrirCobro(detalle.membresia)}><Banknote size={14} className="mr-1" /> Cobrar periodo</Button>
|
||||||
|
)}
|
||||||
|
{can('membresias.cobrar') && detalle.membresia.estado !== 'cancelada' && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => renovar(detalle.membresia)}>Renovar</Button>
|
||||||
|
)}
|
||||||
|
{can('membresias.cancelar') && detalle.membresia.estado === 'activa' && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { setCongelarForm({ desde: '', hasta: '', motivo: '' }); setCongelarTarget(detalle.membresia); }}>
|
||||||
|
<Snowflake size={14} className="mr-1" /> Congelar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('membresias.cancelar') && detalle.membresia.estado === 'congelada' && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => descongelar(detalle.membresia)}>Descongelar</Button>
|
||||||
|
)}
|
||||||
|
{can('membresias.cancelar') && detalle.membresia.estado !== 'cancelada' && (
|
||||||
|
<Button variant="outline" size="sm" className="!text-rose-600 !border-rose-200" onClick={() => cancelar(detalle.membresia)}>
|
||||||
|
<XCircle size={14} className="mr-1" /> Cancelar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-theme-heading mb-2">Periodos y pagos</p>
|
||||||
|
{detalle.periodos.length === 0 ? (
|
||||||
|
<p className="text-sm text-theme-muted">Sin periodos registrados.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl overflow-hidden">
|
||||||
|
{detalle.periodos.map((p) => (
|
||||||
|
<li key={p.id} className="px-3 py-2 text-sm flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-theme-heading truncate">
|
||||||
|
{p.tipo === 'inscripcion' ? 'Inscripción' : `${p.periodo_inicio} a ${p.periodo_fin}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-theme-muted">{p.venta || 'sin venta'}{p.fecha_pago ? ` · ${p.fecha_pago}` : ''}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="tabular-nums text-theme-heading">{fmtMoney(p.monto, p.moneda)}</p>
|
||||||
|
<p className={`text-[11px] ${p.estado === 'pagado' ? 'text-emerald-700' : p.estado === 'vencido' ? 'text-rose-700' : 'text-amber-700'}`}>{p.estado}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-theme-heading mb-2">Consumo de sesiones</p>
|
||||||
|
{detalle.consumos.length === 0 ? (
|
||||||
|
<p className="text-sm text-theme-muted">Todavía no se ha consumido ninguna sesión.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl overflow-hidden">
|
||||||
|
{detalle.consumos.map((c) => (
|
||||||
|
<li key={c.id} className="px-3 py-2 text-sm flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-theme-heading truncate">{c.servicio || 'Sesión'}{c.sesiones > 1 ? ` × ${c.sesiones}` : ''}</p>
|
||||||
|
<p className="text-[11px] text-theme-muted">{c.fecha}{c.usuario ? ` · ${c.usuario}` : ''}{c.state === 'cancelled' ? ' · liberada' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detalle.ventas.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-theme-heading mb-2">Ventas ligadas</p>
|
||||||
|
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl overflow-hidden">
|
||||||
|
{detalle.ventas.map((v) => (
|
||||||
|
<li key={v.id} className="px-3 py-2 text-sm flex items-center justify-between">
|
||||||
|
<span className="text-theme-heading">{v.name} <span className="text-theme-muted text-[11px]">{v.date}</span></span>
|
||||||
|
<span className="tabular-nums text-theme-heading">{fmtMoney(v.total, detalle.membresia.moneda)} <span className="text-[11px] text-theme-muted">{v.state}</span></span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Cobro */}
|
||||||
|
<Modal isOpen={!!cobroTarget} onClose={() => setCobroTarget(null)}
|
||||||
|
title={cobroTarget ? `Cobrar ${cobroTarget.plan} · ${cobroTarget.patient}` : ''}>
|
||||||
|
{cobroTarget && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Método</label>
|
||||||
|
<Select options={METODOS} value={cobroForm.metodo}
|
||||||
|
onChange={(e) => setCobroForm({ ...cobroForm, metodo: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Moneda</label>
|
||||||
|
<Select options={[{ value: 'MXN', label: 'MXN' }, { value: 'USD', label: 'USD' }]}
|
||||||
|
value={cobroForm.moneda} onChange={(e) => setCobroForm({ ...cobroForm, moneda: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Monto</label>
|
||||||
|
<Input type="number" min={0} value={cobroForm.monto}
|
||||||
|
onChange={(e) => setCobroForm({ ...cobroForm, monto: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cobroTarget.estado === 'activa' && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-theme-heading">
|
||||||
|
<input type="checkbox" checked={cobroForm.cobrar_inscripcion}
|
||||||
|
onChange={(e) => setCobroForm({ ...cobroForm, cobrar_inscripcion: e.target.checked })}
|
||||||
|
className="rounded border-theme-border-strong" />
|
||||||
|
Cobrar también la inscripción
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-theme-muted">Se registra una venta y un pago normales; el corte de caja los verá como cualquier cobro.</p>
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button variant="outline" onClick={() => setCobroTarget(null)}>Cancelar</Button>
|
||||||
|
<Button onClick={cobrar} disabled={accionando}>{accionando ? 'Cobrando…' : 'Cobrar'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Congelar */}
|
||||||
|
<Modal isOpen={!!congelarTarget} onClose={() => setCongelarTarget(null)}
|
||||||
|
title={congelarTarget ? `Congelar ${congelarTarget.plan} · ${congelarTarget.patient}` : ''}>
|
||||||
|
{congelarTarget && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Desde</label>
|
||||||
|
<Input type="date" value={congelarForm.desde} onChange={(e) => setCongelarForm({ ...congelarForm, desde: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Hasta</label>
|
||||||
|
<Input type="date" value={congelarForm.hasta} onChange={(e) => setCongelarForm({ ...congelarForm, hasta: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Motivo</label>
|
||||||
|
<Input value={congelarForm.motivo} onChange={(e) => setCongelarForm({ ...congelarForm, motivo: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-theme-muted">Días congelados este año: {congelarTarget.dias_congelados_anio}. El límite lo define la configuración.</p>
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button variant="outline" onClick={() => setCongelarTarget(null)}>Cancelar</Button>
|
||||||
|
<Button onClick={congelar} disabled={accionando}>{accionando ? 'Congelando…' : 'Congelar'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Alta */}
|
||||||
|
<Modal isOpen={nuevaOpen} onClose={() => setNuevaOpen(false)} title="Nueva membresía">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">{term('patient_singular')} *</label>
|
||||||
|
{paciente ? (
|
||||||
|
<div className="flex items-center justify-between rounded-xl border border-theme-border px-3 py-2">
|
||||||
|
<span className="text-sm text-theme-heading">{paciente.name}{paciente.phone ? ` · ${paciente.phone}` : ''}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPaciente(null)}>Cambiar</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input value={pacienteQuery} onChange={(e) => setPacienteQuery(e.target.value)}
|
||||||
|
placeholder={`Buscar ${term('patient_singular').toLowerCase()} por nombre o teléfono`} />
|
||||||
|
{pacienteResults.length > 0 && (
|
||||||
|
<ul className="mt-1 max-h-40 overflow-y-auto rounded-xl border border-theme-border divide-y divide-theme-border">
|
||||||
|
{pacienteResults.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<button type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-theme-bg"
|
||||||
|
onClick={() => { setPaciente(p); setPacienteQuery(''); setPacienteResults([]); }}>
|
||||||
|
<span className="text-theme-heading">{p.name}</span>
|
||||||
|
{p.phone && <span className="text-theme-muted text-[11px] ml-2">{p.phone}</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Plan *</label>
|
||||||
|
<Select
|
||||||
|
options={[{ value: '', label: 'Selecciona un plan…' },
|
||||||
|
...planes.map((p) => ({ value: String(p.id), label: `${p.name} · ${fmtMoney(p.precio, p.moneda)} / ${p.periodicidad}` }))]}
|
||||||
|
value={nueva.plan_id} onChange={(e) => setNueva({ ...nueva, plan_id: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Inicio</label>
|
||||||
|
<Input type="date" value={nueva.fecha_inicio} onChange={(e) => setNueva({ ...nueva, fecha_inicio: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-theme-heading mb-1">Descuento (%)</label>
|
||||||
|
<Input type="number" min={0} max={100} value={nueva.descuento}
|
||||||
|
onChange={(e) => setNueva({ ...nueva, descuento: e.target.value })} placeholder="Por defecto del plan" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-theme-heading">
|
||||||
|
<input type="checkbox" checked={nueva.renovacion_automatica}
|
||||||
|
onChange={(e) => setNueva({ ...nueva, renovacion_automatica: e.target.checked })}
|
||||||
|
className="rounded border-theme-border-strong" />
|
||||||
|
Renovación automática
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setNuevaOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={crear} disabled={accionando}>{accionando ? 'Creando…' : 'Crear membresía'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Membresias;
|
||||||
@@ -11,6 +11,7 @@ import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
|
|||||||
import RecargarSaldoModal from '../components/RecargarSaldoModal';
|
import 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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' ? (
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
3
odoo-addons/skeen_membresias/__init__.py
Normal file
3
odoo-addons/skeen_membresias/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
35
odoo-addons/skeen_membresias/__manifest__.py
Normal file
35
odoo-addons/skeen_membresias/__manifest__.py
Normal 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',
|
||||||
|
}
|
||||||
2
odoo-addons/skeen_membresias/controllers/__init__.py
Normal file
2
odoo-addons/skeen_membresias/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import membresias
|
||||||
717
odoo-addons/skeen_membresias/controllers/membresias.py
Normal file
717
odoo-addons/skeen_membresias/controllers/membresias.py
Normal 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)
|
||||||
21
odoo-addons/skeen_membresias/data/membresias_cron.xml
Normal file
21
odoo-addons/skeen_membresias/data/membresias_cron.xml
Normal 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>
|
||||||
14
odoo-addons/skeen_membresias/data/sequences.xml
Normal file
14
odoo-addons/skeen_membresias/data/sequences.xml
Normal 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>
|
||||||
3
odoo-addons/skeen_membresias/models/__init__.py
Normal file
3
odoo-addons/skeen_membresias/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import membresia
|
||||||
|
from . import venta
|
||||||
806
odoo-addons/skeen_membresias/models/membresia.py
Normal file
806
odoo-addons/skeen_membresias/models/membresia.py
Normal 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
|
||||||
18
odoo-addons/skeen_membresias/models/venta.py
Normal file
18
odoo-addons/skeen_membresias/models/venta.py
Normal 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.')
|
||||||
@@ -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
|
||||||
|
163
odoo-addons/skeen_membresias/views/membresia_views.xml
Normal file
163
odoo-addons/skeen_membresias/views/membresia_views.xml
Normal 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>
|
||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
Reference in New Issue
Block a user