Puntos 16 y 10: modulo de paquetes de sesiones (catalogo, saldo por paciente, descuento en agenda, venta en POS) + fix de auditoria silenciosa
This commit is contained in:
@@ -12,6 +12,7 @@ import Pos from './pages/Pos';
|
||||
import Visitas from './pages/Visitas';
|
||||
import Consultas from './pages/Consultas';
|
||||
import Servicios from './pages/Servicios';
|
||||
import Paquetes from './pages/Paquetes';
|
||||
import Medicos from './pages/Medicos';
|
||||
import Monedero from './pages/Monedero';
|
||||
import Pagos from './pages/Pagos';
|
||||
@@ -58,7 +59,7 @@ const LoginRoute: FC = () => {
|
||||
// lugar de redirigir a "/" en bucle (pantalla en blanco).
|
||||
const LANDING_CANDIDATOS = [
|
||||
'/agenda', '/mis-ventas', '/pacientes', '/pos', '/visitas', '/consultas',
|
||||
'/medicos', '/servicios', '/ventas', '/pagos', '/monedero',
|
||||
'/medicos', '/servicios', '/paquetes', '/ventas', '/pagos', '/monedero',
|
||||
'/inventario', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages',
|
||||
'/wacrm/leads', '/configuracion',
|
||||
];
|
||||
@@ -99,6 +100,7 @@ const App: FC = () => {
|
||||
<Route path="/ultimas-visitas" element={<Navigate to="/pacientes" replace />} />
|
||||
<Route path="/servicios" element={<RequireAuth href="/servicios"><Servicios /></RequireAuth>} />
|
||||
<Route path="/medicos" element={<RequireAuth href="/medicos"><Medicos /></RequireAuth>} />
|
||||
<Route path="/paquetes" element={<RequireAuth href="/paquetes"><Paquetes /></RequireAuth>} />
|
||||
<Route path="/productos" element={<Navigate to="/inventario" replace />} />
|
||||
<Route path="/monedero" element={<RequireAuth href="/monedero"><Monedero /></RequireAuth>} />
|
||||
<Route path="/pagos" element={<RequireAuth href="/pagos"><Pagos /></RequireAuth>} />
|
||||
|
||||
@@ -13,6 +13,11 @@ const MODULOS = [
|
||||
{ value: 'roles', label: 'Roles y permisos' },
|
||||
{ value: 'usuarios', label: 'Usuarios' },
|
||||
{ value: 'catalogos', label: 'Catálogos' },
|
||||
{ value: 'pacientes', label: 'Expedientes de pacientes' },
|
||||
{ value: 'agenda', label: 'Agenda' },
|
||||
{ value: 'paquetes', label: 'Paquetes' },
|
||||
{ value: 'monedero', label: 'Monedero' },
|
||||
{ value: 'clinica', label: 'Clínica' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -70,6 +70,7 @@ const menuTree: MenuEntry[] = [
|
||||
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
|
||||
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||
{ label: 'Cosmetología', icon: FlaskConical, href: '/servicios' },
|
||||
{ label: 'Paquetes', icon: Boxes, href: '/paquetes' },
|
||||
{
|
||||
label: 'Configuración', icon: Settings,
|
||||
children: [
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
MessageCircle,
|
||||
CircleDollarSign,
|
||||
TicketCheck,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
@@ -71,6 +72,7 @@ const menuSections = [
|
||||
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
|
||||
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
||||
{ label: 'Paquetes', icon: Layers, href: '/paquetes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Ticket,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
@@ -71,6 +72,7 @@ const menuGroups = [
|
||||
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
|
||||
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
||||
{ label: 'Paquetes', icon: Layers, href: '/paquetes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
|
||||
'/medicos': ['admin', 'recepcion', 'gerencia'],
|
||||
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||
'/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia', 'cosmetologia'],
|
||||
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario
|
||||
'/ventas': ['admin', 'recepcion', 'gerencia'],
|
||||
'/tickets': ['admin', 'gerencia'],
|
||||
|
||||
830
frontend/src/pages/Paquetes.tsx
Normal file
830
frontend/src/pages/Paquetes.tsx
Normal file
@@ -0,0 +1,830 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Layers, Plus, Pencil, Search, XCircle, SlidersHorizontal, RefreshCw,
|
||||
CheckCircle2, Clock, AlertTriangle, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import {
|
||||
Card, Button, Input, Select, TextArea, Modal, PageHeader, EmptyState, Skeleton,
|
||||
MobileCard, toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi } from '../services/odoo';
|
||||
import type { Paquete, PaqueteCompra, PaqueteConsumo, Service } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
|
||||
const STATE_META: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: 'Vigente', cls: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
|
||||
finished: { label: 'Completado', cls: 'bg-theme-bg text-theme-muted border-theme-border' },
|
||||
expired: { label: 'Vencido', cls: 'bg-amber-100 text-amber-700 border-amber-200' },
|
||||
cancelled: { label: 'Anulado', cls: 'bg-rose-100 text-rose-700 border-rose-200' },
|
||||
};
|
||||
|
||||
const FILTROS_SALDO = [
|
||||
{ key: '', label: 'Todos' },
|
||||
{ key: 'active', label: 'Vigentes', icon: CheckCircle2 },
|
||||
{ key: 'expired', label: 'Por vencer / vencidos', icon: AlertTriangle },
|
||||
{ key: 'finished', label: 'Completados', icon: Clock },
|
||||
{ key: 'cancelled', label: 'Anulados', icon: XCircle },
|
||||
];
|
||||
|
||||
interface FormPaquete {
|
||||
id?: number;
|
||||
name: string;
|
||||
code: string;
|
||||
service_id: string;
|
||||
sessions: string;
|
||||
price: string;
|
||||
validity_days: string;
|
||||
duration_min: string;
|
||||
notes: string;
|
||||
genera_monedero: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: FormPaquete = {
|
||||
name: '', code: '', service_id: '', sessions: '3', price: '',
|
||||
validity_days: '90', duration_min: '0', notes: '', genera_monedero: true,
|
||||
};
|
||||
|
||||
const Paquetes: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') === 'saldos' ? 'saldos' : 'catalogo';
|
||||
const setTab = (key: string) =>
|
||||
setSearchParams(key === 'catalogo' ? {} : { tab: key }, { replace: true });
|
||||
const { hasRole } = useAuth();
|
||||
const esAdmin = hasRole('admin');
|
||||
|
||||
// ---- Catálogo ----
|
||||
const [paquetes, setPaquetes] = useState<Paquete[]>([]);
|
||||
const [servicios, setServicios] = useState<Service[]>([]);
|
||||
const [cargando, setCargando] = useState(true);
|
||||
const [busqueda, setBusqueda] = useState('');
|
||||
const [mostrarBaja, setMostrarBaja] = useState(false);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [form, setForm] = useState<FormPaquete>(emptyForm);
|
||||
const [guardando, setGuardando] = useState(false);
|
||||
|
||||
// ---- Saldos ----
|
||||
const [compras, setCompras] = useState<PaqueteCompra[]>([]);
|
||||
const [totales, setTotales] = useState<Record<string, number>>({});
|
||||
const [cargandoSaldos, setCargandoSaldos] = useState(false);
|
||||
const [filtroEstado, setFiltroEstado] = useState('');
|
||||
const [busquedaSaldo, setBusquedaSaldo] = useState('');
|
||||
const [detalle, setDetalle] = useState<{ compra: PaqueteCompra; consumos: PaqueteConsumo[] } | null>(null);
|
||||
const [cargandoDetalle, setCargandoDetalle] = useState(false);
|
||||
const [ajuste, setAjuste] = useState<{ compra: PaqueteCompra; total: string; usadas: string } | null>(null);
|
||||
|
||||
const loadCatalogo = useCallback(async () => {
|
||||
try {
|
||||
setCargando(true);
|
||||
const res = await odooApi.getPaqueteCatalogo({ includeInactive: mostrarBaja });
|
||||
if (res.status === 'success') setPaquetes(res.paquetes);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Error al cargar los paquetes');
|
||||
} finally {
|
||||
setCargando(false);
|
||||
}
|
||||
}, [mostrarBaja]);
|
||||
|
||||
const loadSaldos = useCallback(async () => {
|
||||
try {
|
||||
setCargandoSaldos(true);
|
||||
const res = await odooApi.getPaqueteCompras({
|
||||
state: filtroEstado || undefined,
|
||||
search: busquedaSaldo.trim() || undefined,
|
||||
includeAll: filtroEstado === 'cancelled' || mostrarBaja,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
setCompras(res.compras);
|
||||
setTotales(res.totales || {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Error al cargar los saldos');
|
||||
} finally {
|
||||
setCargandoSaldos(false);
|
||||
}
|
||||
}, [filtroEstado, busquedaSaldo, mostrarBaja]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getServices({ page_size: 200 })
|
||||
.then((res) => { if (res.status === 'success') setServicios(res.services); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (tab === 'catalogo') loadCatalogo(); }, [tab, loadCatalogo]);
|
||||
useEffect(() => {
|
||||
if (tab !== 'saldos') return;
|
||||
const t = setTimeout(loadSaldos, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [tab, loadSaldos]);
|
||||
|
||||
const filtrados = useMemo(() => {
|
||||
const q = busqueda.trim().toLowerCase();
|
||||
if (!q) return paquetes;
|
||||
return paquetes.filter((p) =>
|
||||
p.name.toLowerCase().includes(q) || (p.service || '').toLowerCase().includes(q)
|
||||
|| (p.code || '').toLowerCase().includes(q));
|
||||
}, [paquetes, busqueda]);
|
||||
|
||||
const sinBaja = useMemo(() => filtrados.filter((p) => p.active), [filtrados]);
|
||||
const dadosDeBaja = useMemo(() => filtrados.filter((p) => !p.active), [filtrados]);
|
||||
const visibles = mostrarBaja ? filtrados : sinBaja;
|
||||
|
||||
const abrirNuevo = () => {
|
||||
setForm(emptyForm);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const abrirEditar = (p: Paquete) => {
|
||||
setForm({
|
||||
id: p.id, name: p.name, code: p.code, service_id: String(p.service_id ?? ''),
|
||||
sessions: String(p.sessions), price: String(p.price),
|
||||
validity_days: String(p.validity_days || 0), duration_min: String(p.duration_min || 0),
|
||||
notes: p.notes || '', genera_monedero: p.genera_monedero,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const guardar = async () => {
|
||||
if (!form.name.trim()) { toast.error('El nombre es obligatorio'); return; }
|
||||
if (!form.service_id) { toast.error('Selecciona el servicio'); return; }
|
||||
const sesiones = parseInt(form.sessions, 10);
|
||||
const precio = parseFloat(form.price);
|
||||
if (!sesiones || sesiones < 1) { toast.error('Debe tener al menos 1 sesión'); return; }
|
||||
if (Number.isNaN(precio) || precio <= 0) { toast.error('El precio debe ser mayor a 0'); return; }
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
service_id: parseInt(form.service_id, 10),
|
||||
sessions: sesiones,
|
||||
price: precio,
|
||||
validity_days: parseInt(form.validity_days, 10) || 0,
|
||||
duration_min: parseInt(form.duration_min, 10) || 0,
|
||||
notes: form.notes.trim(),
|
||||
genera_monedero: form.genera_monedero,
|
||||
};
|
||||
try {
|
||||
setGuardando(true);
|
||||
const res = form.id
|
||||
? await odooApi.updatePaquete(form.id, payload)
|
||||
: await odooApi.createPaquete(payload);
|
||||
if (res.status !== 'success') {
|
||||
toast.error(res.message || 'No se pudo guardar');
|
||||
return;
|
||||
}
|
||||
toast.success(form.id ? 'Paquete actualizado' : 'Paquete creado');
|
||||
setFormOpen(false);
|
||||
loadCatalogo();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al guardar el paquete');
|
||||
} finally {
|
||||
setGuardando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cambiarActivo = async (p: Paquete) => {
|
||||
const confirmar = p.active
|
||||
? `¿Dar de baja "${p.name}? Dejará de ofrecerse en el punto de venta. Los paquetes ya vendidos no se tocan.`
|
||||
: `¿Reactivar "${p.name}"?`;
|
||||
if (!window.confirm(confirmar)) return;
|
||||
try {
|
||||
const res = await odooApi.updatePaquete(p.id, { active: !p.active });
|
||||
if (res.status !== 'success') { toast.error(res.message || 'No se pudo cambiar el estado'); return; }
|
||||
toast.success(p.active ? 'Paquete dado de baja' : 'Paquete reactivado');
|
||||
loadCatalogo();
|
||||
} catch {
|
||||
toast.error('Error al cambiar el estado');
|
||||
}
|
||||
};
|
||||
|
||||
const abrirDetalle = async (c: PaqueteCompra) => {
|
||||
setCargandoDetalle(true);
|
||||
setDetalle(null);
|
||||
try {
|
||||
const res = await odooApi.getPaqueteConsumos(c.id);
|
||||
if (res.status === 'success') setDetalle({ compra: res.compra, consumos: res.consumos });
|
||||
else toast.error(res.message || 'No se pudo abrir el paquete');
|
||||
} catch {
|
||||
toast.error('Error al abrir el paquete');
|
||||
} finally {
|
||||
setCargandoDetalle(false);
|
||||
}
|
||||
};
|
||||
|
||||
const guardarAjuste = async () => {
|
||||
if (!ajuste) return;
|
||||
const total = parseInt(ajuste.total, 10);
|
||||
const usadas = parseInt(ajuste.usadas, 10);
|
||||
if (Number.isNaN(total) || total < 0 || Number.isNaN(usadas) || usadas < 0) {
|
||||
toast.error('Las sesiones deben ser números');
|
||||
return;
|
||||
}
|
||||
if (usadas > total) { toast.error('Las usadas no pueden ser mayores al total'); return; }
|
||||
try {
|
||||
const res = await odooApi.ajustarPaqueteCompra(ajuste.compra.id, {
|
||||
sessions_total: total, sessions_used: usadas,
|
||||
});
|
||||
if (res.status !== 'success') { toast.error(res.message || 'No se pudo ajustar'); return; }
|
||||
toast.success('Saldo ajustado');
|
||||
setAjuste(null);
|
||||
loadSaldos();
|
||||
} catch {
|
||||
toast.error('Error al ajustar el saldo');
|
||||
}
|
||||
};
|
||||
|
||||
const anular = async (c: PaqueteCompra) => {
|
||||
const motivo = window.prompt(
|
||||
`¿Anular "${c.paquete}" de ${c.patient}? Se liberan las sesiones consumidas.\n\nMotivo (obligatorio):`
|
||||
);
|
||||
if (!motivo || !motivo.trim()) {
|
||||
if (motivo !== null) toast.error('Indica el motivo de la anulación');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await odooApi.anularPaqueteCompra(c.id, motivo.trim());
|
||||
if (res.status !== 'success') { toast.error(res.message || 'No se pudo anular'); return; }
|
||||
toast.success('Paquete anulado');
|
||||
setDetalle(null);
|
||||
loadSaldos();
|
||||
} catch {
|
||||
toast.error('Error al anular el paquete');
|
||||
}
|
||||
};
|
||||
|
||||
const progreso = (c: PaqueteCompra) =>
|
||||
c.sessions_total > 0 ? Math.round((c.sessions_used / c.sessions_total) * 100) : 0;
|
||||
|
||||
const diasParaVencer = (c: PaqueteCompra) => {
|
||||
if (!c.expiry_date) return null;
|
||||
const diff = Math.ceil((new Date(c.expiry_date + 'T00:00:00').getTime() - Date.now()) / 86400000);
|
||||
return diff;
|
||||
};
|
||||
|
||||
const FilaSaldo: FC<{ c: PaqueteCompra }> = ({ c }) => {
|
||||
const dias = diasParaVencer(c);
|
||||
const meta = STATE_META[c.state] || STATE_META.active;
|
||||
const cercaDeVencer = c.state === 'active' && dias !== null && dias <= 15;
|
||||
return (
|
||||
<tr className="hover:bg-theme-bg cursor-pointer" onClick={() => abrirDetalle(c)}>
|
||||
<td className="px-4 py-3 text-theme-muted whitespace-nowrap">{c.date || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-theme-heading">{c.patient}</p>
|
||||
{c.phone && <p className="text-[11px] text-theme-muted">{c.phone}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-theme-heading">{c.paquete}</p>
|
||||
<p className="text-[11px] text-theme-muted">{c.service}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-[170px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 rounded-full bg-theme-bg overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${c.state === 'expired' ? 'bg-amber-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${progreso(c)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-theme-heading whitespace-nowrap tabular-nums">
|
||||
{c.sessions_used}/{c.sessions_total}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-theme-muted mt-0.5">
|
||||
{c.sessions_remaining} restantes
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
{c.expiry_date ? (
|
||||
<span className={cercaDeVencer ? 'text-amber-700 font-medium' : 'text-theme-muted'}>
|
||||
{c.expiry_date}
|
||||
{cercaDeVencer && (
|
||||
<span className="block text-[10px] font-normal">
|
||||
{dias >= 0 ? `en ${dias} días` : 'vencido'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-theme-muted">Sin límite</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-theme-heading tabular-nums">{fmtMoney(c.price)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block text-[11px] font-medium px-2 py-0.5 rounded-full border ${meta.cls}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<ChevronRight size={16} className="inline text-theme-muted" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Paquetes" subtitle="Paquetes de sesiones">
|
||||
<PageHeader
|
||||
title="Paquetes de sesiones"
|
||||
subtitle="Combos de sesiones a precio cerrado y el saldo que lleva cada paciente"
|
||||
>
|
||||
<Button variant="outline" onClick={() => (tab === 'catalogo' ? loadCatalogo() : loadSaldos())}>
|
||||
<RefreshCw size={16} className="mr-2" /> Actualizar
|
||||
</Button>
|
||||
{tab === 'catalogo' && esAdmin && (
|
||||
<Button onClick={abrirNuevo}>
|
||||
<Plus size={16} className="mr-2" /> Nuevo paquete
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
{[{ key: 'catalogo', label: 'Catálogo' }, { key: 'saldos', label: 'Paquetes vendidos' }].map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
|
||||
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'catalogo' ? (
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div className="relative w-full max-w-sm">
|
||||
<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 nombre, servicio o código"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-theme-muted shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mostrarBaja}
|
||||
onChange={(e) => setMostrarBaja(e.target.checked)}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
Mostrar dados de baja{dadosDeBaja.length > 0 ? ` (${dadosDeBaja.length})` : ''}
|
||||
</label>
|
||||
</Card.Header>
|
||||
<Card.Body noPadding>
|
||||
{cargando ? (
|
||||
<div className="p-6"><Skeleton count={6} /></div>
|
||||
) : visibles.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin paquetes"
|
||||
subtitle="Crea el primero para poder venderlo en el punto de venta."
|
||||
icon={<Layers size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Escritorio */}
|
||||
<div className="overflow-x-auto hidden sm:block">
|
||||
<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">Paquete</th>
|
||||
<th className="text-left px-4 py-3">Servicio</th>
|
||||
<th className="text-right px-4 py-3">Sesiones</th>
|
||||
<th className="text-right px-4 py-3">Precio</th>
|
||||
<th className="text-right px-4 py-3">Por sesión</th>
|
||||
<th className="text-right px-4 py-3">Ahorro</th>
|
||||
<th className="text-left px-4 py-3">Vigencia</th>
|
||||
<th className="text-right px-4 py-3">Vendidos</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">
|
||||
{visibles.map((p) => {
|
||||
const ahorro = p.session_price && p.price_per_session
|
||||
? Math.round((1 - p.price_per_session / p.session_price) * 100) : 0;
|
||||
return (
|
||||
<tr key={p.id} className={!p.active ? 'opacity-60' : ''}>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-theme-heading">{p.name}</p>
|
||||
{p.code && <p className="text-[11px] text-theme-muted">{p.code}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-theme-muted">{p.service}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{p.sessions}</td>
|
||||
<td className="px-4 py-3 text-right font-medium text-theme-heading tabular-nums">{fmtMoney(p.price)}</td>
|
||||
<td className="px-4 py-3 text-right text-theme-muted tabular-nums">{fmtMoney(p.price_per_session)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{ahorro > 0 ? (
|
||||
<span className="text-emerald-600 font-medium">−{ahorro}%</span>
|
||||
) : (
|
||||
<span className="text-theme-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-theme-muted">
|
||||
{p.validity_days > 0 ? `${p.validity_days} días` : 'Sin límite'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{p.compras_count}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={p.active}
|
||||
onClick={() => cambiarActivo(p)}
|
||||
disabled={!esAdmin}
|
||||
title={p.active ? 'Dar de baja' : 'Reactivar'}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[11px] font-medium transition-colors disabled:opacity-50 ${
|
||||
p.active
|
||||
? 'border-emerald-300 bg-emerald-50 text-emerald-800'
|
||||
: 'border-rose-300 bg-rose-50 text-rose-700'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${p.active ? 'bg-emerald-600' : 'bg-rose-500'}`} />
|
||||
{p.active ? 'Activo' : 'Baja'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => abrirEditar(p)}
|
||||
disabled={!esAdmin}
|
||||
title="Editar paquete"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Móvil */}
|
||||
<div className="sm:hidden divide-y divide-theme-border">
|
||||
{visibles.map((p) => (
|
||||
<MobileCard
|
||||
key={p.id}
|
||||
title={p.name}
|
||||
subtitle={p.service}
|
||||
rows={[
|
||||
{ label: 'Sesiones', value: p.sessions },
|
||||
{ label: 'Precio', value: fmtMoney(p.price) },
|
||||
{ label: 'Por sesión', value: fmtMoney(p.price_per_session) },
|
||||
{ label: 'Estado', value: p.active ? 'Activo' : 'Baja' },
|
||||
]}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => abrirEditar(p)} disabled={!esAdmin}>
|
||||
<Pencil size={13} className="mr-1" /> Editar
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Card.Header>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{FILTROS_SALDO.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFiltroEstado(f.key)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition flex items-center gap-1 ${
|
||||
filtroEstado === f.key
|
||||
? 'bg-theme-accent text-theme-inverse'
|
||||
: 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
{f.icon && <f.icon size={12} />}
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative w-full max-w-xs">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
value={busquedaSaldo}
|
||||
onChange={(e) => setBusquedaSaldo(e.target.value)}
|
||||
placeholder="Buscar paciente o paquete"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
{tab === 'saldos' && (
|
||||
<div className="px-4 sm:px-6 py-3 border-b border-theme-border flex flex-wrap gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-theme-muted">Vigentes: </span>
|
||||
<span className="font-semibold text-theme-heading">{totales.vigentes ?? 0}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-theme-muted">Sesiones por usar: </span>
|
||||
<span className="font-semibold text-theme-heading">{totales.sesiones_restantes ?? 0}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-theme-muted">Por vencer: </span>
|
||||
<span className="font-semibold text-amber-700">{totales.vencidos ?? 0}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-theme-muted">Vendido: </span>
|
||||
<span className="font-semibold text-theme-heading">{fmtMoney(totales.importe ?? 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card.Body noPadding>
|
||||
{cargandoSaldos ? (
|
||||
<div className="p-6"><Skeleton count={6} /></div>
|
||||
) : compras.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin paquetes vendidos"
|
||||
subtitle="Los paquetes aparecen aquí en cuanto se cobren en el punto de venta."
|
||||
icon={<SlidersHorizontal size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto hidden sm:block">
|
||||
<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">Compra</th>
|
||||
<th className="text-left px-4 py-3">Paciente</th>
|
||||
<th className="text-left px-4 py-3">Paquete</th>
|
||||
<th className="text-left px-4 py-3">Sesiones</th>
|
||||
<th className="text-left px-4 py-3">Vence</th>
|
||||
<th className="text-right px-4 py-3">Pagado</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">
|
||||
{compras.map((c) => <FilaSaldo key={c.id} c={c} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="sm:hidden divide-y divide-theme-border">
|
||||
{compras.map((c) => (
|
||||
<MobileCard
|
||||
key={c.id}
|
||||
title={c.patient}
|
||||
subtitle={`${c.paquete} · ${c.date || ''}`}
|
||||
rows={[
|
||||
{ label: 'Usadas', value: `${c.sessions_used} de ${c.sessions_total}` },
|
||||
{ label: 'Restantes', value: c.sessions_remaining },
|
||||
{ label: 'Vence', value: c.expiry_date || 'Sin límite' },
|
||||
{ label: 'Estado', value: (STATE_META[c.state] || STATE_META.active).label },
|
||||
]}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => abrirDetalle(c)}>
|
||||
Ver detalle
|
||||
</Button>
|
||||
}
|
||||
onClick={() => abrirDetalle(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Alta / edición de paquete */}
|
||||
<Modal
|
||||
isOpen={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
title={form.id ? 'Editar paquete' : 'Nuevo paquete de sesiones'}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1">Nombre *</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Ej. Lumecca paquete 3 sesiones"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1">Servicio *</label>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Selecciona un servicio…' },
|
||||
...servicios.map((s) => ({ value: String(s.id), label: `${s.name} · ${fmtMoney(s.price)} / sesión` })),
|
||||
]}
|
||||
value={form.service_id}
|
||||
onChange={(e) => {
|
||||
const sv = servicios.find((s) => String(s.id) === e.target.value);
|
||||
setForm({
|
||||
...form,
|
||||
service_id: e.target.value,
|
||||
duration_min: sv ? String(sv.duration_min || 0) : form.duration_min,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1">Sesiones *</label>
|
||||
<Input
|
||||
type="number" min={1} value={form.sessions}
|
||||
onChange={(e) => setForm({ ...form, sessions: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1">Precio del paquete *</label>
|
||||
<Input
|
||||
type="number" min={0} step={50} value={form.price}
|
||||
onChange={(e) => setForm({ ...form, price: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1">Vigencia (días)</label>
|
||||
<Input
|
||||
type="number" min={0} value={form.validity_days}
|
||||
onChange={(e) => setForm({ ...form, validity_days: e.target.value })}
|
||||
placeholder="0 = sin límite"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1">Duración sesión (min)</label>
|
||||
<Input
|
||||
type="number" min={0} value={form.duration_min}
|
||||
onChange={(e) => setForm({ ...form, duration_min: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(parseInt(form.sessions, 10) > 0 && parseFloat(form.price) > 0) && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
Sale a {fmtMoney(parseFloat(form.price) / parseInt(form.sessions, 10))} por sesión.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1">Código</label>
|
||||
<Input value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-theme-heading mt-6">
|
||||
<input
|
||||
type="checkbox" checked={form.genera_monedero}
|
||||
onChange={(e) => setForm({ ...form, genera_monedero: e.target.checked })}
|
||||
className="rounded border-theme-border-strong"
|
||||
/>
|
||||
Genera monedero
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1">Notas</label>
|
||||
<TextArea
|
||||
rows={2} value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="Condiciones del paquete, restricciones, promociones…"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={guardar} disabled={guardando}>
|
||||
{guardando ? 'Guardando…' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Detalle de un paquete vendido */}
|
||||
<Modal
|
||||
isOpen={!!detalle || cargandoDetalle}
|
||||
onClose={() => { setDetalle(null); setAjuste(null); }}
|
||||
title={detalle ? `Paquete de ${detalle.compra.patient}` : 'Cargando…'}
|
||||
>
|
||||
{detalle && !ajuste && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-theme-muted">Paquete</p>
|
||||
<p className="font-medium text-theme-heading">{detalle.compra.paquete}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-theme-muted">Servicio</p>
|
||||
<p className="text-theme-heading">{detalle.compra.service || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-theme-muted">Comprado</p>
|
||||
<p className="text-theme-heading">
|
||||
{detalle.compra.date}{detalle.compra.venta ? ` · ${detalle.compra.venta}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-theme-muted">Vence</p>
|
||||
<p className="text-theme-heading">{detalle.compra.expiry_date || 'Sin límite'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-theme-bg px-4 py-3">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-theme-muted">Sesiones consumidas</span>
|
||||
<span className="font-semibold text-theme-heading tabular-nums">
|
||||
{detalle.compra.sessions_used} de {detalle.compra.sessions_total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-theme-surface overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${detalle.compra.state === 'expired' ? 'bg-amber-500' : 'bg-emerald-500'}`}
|
||||
style={{ width: `${progreso(detalle.compra)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-theme-muted mt-1.5">
|
||||
Quedan {detalle.compra.sessions_remaining} sesiones por agendar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-theme-heading mb-2">Bitácora 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((m) => (
|
||||
<li key={m.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">
|
||||
{m.cita_fecha || '—'} {m.cita_hora}
|
||||
{m.doctor ? <span className="text-theme-muted"> · {m.doctor}</span> : null}
|
||||
</p>
|
||||
<p className="text-[11px] text-theme-muted">
|
||||
{m.state === 'cancelled' ? 'Sesión liberada (cita cancelada)' : 'Sesión consumida'}
|
||||
</p>
|
||||
</div>
|
||||
{m.cita_id && (
|
||||
<a
|
||||
href={`/agenda?cita=${m.cita_id}`}
|
||||
className="text-xs text-theme-accent shrink-0"
|
||||
onClick={() => setDetalle(null)}
|
||||
>
|
||||
Ver cita
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 pt-1">
|
||||
<Button variant="outline" size="sm" onClick={() => setAjuste({
|
||||
compra: detalle.compra,
|
||||
total: String(detalle.compra.sessions_total),
|
||||
usadas: String(detalle.compra.sessions_used),
|
||||
})}>
|
||||
Ajustar saldo
|
||||
</Button>
|
||||
{hasRole('gerencia') && detalle.compra.state !== 'cancelled' && (
|
||||
<Button variant="outline" size="sm" className="!text-rose-600 !border-rose-200" onClick={() => anular(detalle.compra)}>
|
||||
Anular paquete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detalle && ajuste && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Corrige el saldo si las sesiones se registraron mal. Se audita en Configuración.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1">Sesiones contratadas</label>
|
||||
<Input type="number" min={0} value={ajuste.total}
|
||||
onChange={(e) => setAjuste({ ...ajuste, total: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme-heading mb-1">Sesiones usadas</label>
|
||||
<Input type="number" min={0} value={ajuste.usadas}
|
||||
onChange={(e) => setAjuste({ ...ajuste, usadas: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setAjuste(null)}>Volver</Button>
|
||||
<Button onClick={guardarAjuste}>Guardar ajuste</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Paquetes;
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package,
|
||||
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List,
|
||||
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List, Layers,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Skeleton,
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor } from '../services/odoo';
|
||||
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
|
||||
import type { PatientPackage } from '../services/odoo';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
@@ -43,6 +43,8 @@ interface TicketLine {
|
||||
price: number;
|
||||
service_id?: number;
|
||||
item_id?: number;
|
||||
paquete_id?: number;
|
||||
sesiones?: number;
|
||||
stock?: number;
|
||||
unit?: string;
|
||||
}
|
||||
@@ -51,7 +53,9 @@ const Pos: FC = () => {
|
||||
const { openPatient } = usePatientDrawer();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Catálogo
|
||||
const [catMode, setCatMode] = useState<'servicios' | 'articulos'>('servicios');
|
||||
const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios');
|
||||
// Catálogo de paquetes de sesiones (skeen.paquete)
|
||||
const [paqueteCatalogo, setPaqueteCatalogo] = useState<Paquete[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -162,14 +166,31 @@ const Pos: FC = () => {
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
// Paquetes de sesiones: catálogo corto, se carga completo y se filtra en local
|
||||
const loadPaquetes = useCallback(async () => {
|
||||
try {
|
||||
setCatalogLoading(true);
|
||||
const res = await odooApi.getPaqueteCatalogo();
|
||||
if (res.status === 'success') setPaqueteCatalogo(res.paquetes);
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar paquetes');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setCatalogLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (catMode === 'servicios') loadServices();
|
||||
else loadItems();
|
||||
else if (catMode === 'articulos') loadItems();
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [loadServices, loadItems, catMode]);
|
||||
|
||||
// El catálogo de paquetes es corto: se carga una vez y se filtra en local.
|
||||
useEffect(() => { loadPaquetes(); }, [loadPaquetes]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getCashClosings(new Date().toISOString().split('T')[0])
|
||||
.then((res) => {
|
||||
@@ -292,9 +313,43 @@ const Pos: FC = () => {
|
||||
return arr;
|
||||
}, [visiblesItems, ordenLista]);
|
||||
|
||||
// ---- Ticket ----
|
||||
const addLine = (service: Service) => {
|
||||
// Paquetes visibles: filtro local por nombre / servicio / código
|
||||
const paquetesVisibles = useMemo(() => {
|
||||
const q = (search || '').trim().toLowerCase();
|
||||
const arr = !q
|
||||
? [...paqueteCatalogo]
|
||||
: paqueteCatalogo.filter((p) =>
|
||||
p.name.toLowerCase().includes(q)
|
||||
|| (p.service || '').toLowerCase().includes(q)
|
||||
|| (p.code || '').toLowerCase().includes(q));
|
||||
arr.sort((a, b) => (a.sequence || 0) - (b.sequence || 0) || a.name.localeCompare(b.name, 'es'));
|
||||
return arr;
|
||||
}, [paqueteCatalogo, search]);
|
||||
|
||||
// Un paquete solo puede venderse a un paciente: el saldo vive en él.
|
||||
const addPaqueteLine = (p: Paquete) => {
|
||||
if (!paciente) {
|
||||
toast.error('Selecciona primero el paciente: el saldo del paquete es por paciente');
|
||||
return;
|
||||
}
|
||||
setLines((prev) => {
|
||||
const key = `p-${p.id}`;
|
||||
const found = prev.find((l) => l.key === key);
|
||||
if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l));
|
||||
return [...prev, {
|
||||
key,
|
||||
name: `${p.name} · Paquete (${p.sessions} sesiones)`,
|
||||
qty: 1,
|
||||
price: p.price,
|
||||
service_id: p.service_id ?? undefined,
|
||||
paquete_id: p.id,
|
||||
sesiones: p.sessions,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Ticket ----
|
||||
const addLine = (service: Service) => { setLines((prev) => {
|
||||
const key = `s-${service.id}`;
|
||||
const found = prev.find((l) => l.key === key);
|
||||
if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l));
|
||||
@@ -382,6 +437,7 @@ const Pos: FC = () => {
|
||||
lines: lines.map((l) => ({
|
||||
...(l.service_id ? { service_id: l.service_id } : {}),
|
||||
...(l.item_id ? { item_id: l.item_id } : {}),
|
||||
...(l.paquete_id ? { paquete_id: l.paquete_id, description: l.name } : {}),
|
||||
quantity: l.qty,
|
||||
price_unit: l.price,
|
||||
// Asignación a nivel venta: el doctor seleccionado aplica a todas las líneas
|
||||
@@ -833,13 +889,13 @@ const Pos: FC = () => {
|
||||
<div className="relative mb-3" ref={searchWrapRef}>
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder={catMode === 'servicios' ? 'Buscar servicio... (F2)' : 'Buscar artículo... (F2)'}
|
||||
placeholder={catMode === 'servicios' ? 'Buscar servicio... (F2)' : catMode === 'paquetes' ? 'Buscar paquete... (F2)' : 'Buscar artículo... (F2)'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
{/* Toggle Servicios / Artículos */}
|
||||
{/* Toggle Servicios / Paquetes / Artículos */}
|
||||
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -850,6 +906,19 @@ const Pos: FC = () => {
|
||||
>
|
||||
<Store size={14} /> Servicios
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatMode('paquetes')}
|
||||
className={`px-4 py-1.5 text-sm font-medium transition flex items-center gap-1.5 ${
|
||||
catMode === 'paquetes' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||||
}`}
|
||||
title="Paquetes de sesiones"
|
||||
>
|
||||
<Layers size={14} /> Paquetes
|
||||
{paqueteCatalogo.length > 0 && (
|
||||
<span className="text-[10px] opacity-80">({paqueteCatalogo.length})</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatMode('articulos')}
|
||||
@@ -916,6 +985,7 @@ const Pos: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catMode !== 'paquetes' && (
|
||||
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3 ml-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -938,9 +1008,57 @@ const Pos: FC = () => {
|
||||
<List size={12} /> Lista
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalogLoading ? (
|
||||
<Skeleton count={8} className="h-16 w-full" />
|
||||
) : catMode === 'paquetes' ? (
|
||||
paquetesVisibles.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin paquetes"
|
||||
subtitle={paqueteCatalogo.length === 0
|
||||
? 'Aún no hay paquetes dados de alta. Créalos en el módulo Paquetes.'
|
||||
: 'Ningún paquete coincide con la búsqueda.'}
|
||||
icon={<Layers size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||
{paquetesVisibles.map((p) => {
|
||||
const ahorro = p.session_price && p.price_per_session
|
||||
? Math.round((1 - p.price_per_session / p.session_price) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => addPaqueteLine(p)}
|
||||
className="text-left p-2.5 bg-theme-bg rounded-xl border border-theme-border hover:shadow-card hover:border-theme-border-strong transition"
|
||||
title={p.notes || p.name}
|
||||
>
|
||||
<p className="text-sm font-medium text-theme-heading leading-tight line-clamp-2 min-h-[2.4rem]">
|
||||
{p.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-theme-muted truncate">{p.service}</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-theme-muted">{p.sessions} sesiones</span>
|
||||
<span className="text-sm font-bold text-theme-heading">{fmtMoney(p.price)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className="text-[10px] text-theme-muted">
|
||||
{fmtMoney(p.price_per_session)} / sesión
|
||||
</span>
|
||||
{ahorro > 0 && (
|
||||
<span className="text-[10px] font-semibold text-emerald-600">-{ahorro}%</span>
|
||||
)}
|
||||
</div>
|
||||
{p.validity_days > 0 && (
|
||||
<p className="text-[10px] text-theme-muted mt-0.5">Vigencia {p.validity_days} días</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : vista === 'lista' ? (
|
||||
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto border border-theme-border rounded-xl">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -543,6 +543,8 @@ export interface LastSaleResponse {
|
||||
}
|
||||
|
||||
export interface PatientPackage {
|
||||
id?: number | null;
|
||||
compra?: boolean;
|
||||
service_id: number;
|
||||
name: string;
|
||||
package_sessions: number;
|
||||
@@ -550,6 +552,74 @@ export interface PatientPackage {
|
||||
used: number;
|
||||
remaining: number;
|
||||
next_date: string | null;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
// ---------- Paquetes de sesiones ----------
|
||||
export interface Paquete {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
service_id: number | null;
|
||||
service: string;
|
||||
sessions: number;
|
||||
price: number;
|
||||
price_per_session: number;
|
||||
session_price: number;
|
||||
duration_min: number;
|
||||
validity_days: number;
|
||||
notes: string;
|
||||
color: string;
|
||||
genera_monedero: boolean;
|
||||
active: boolean;
|
||||
sequence: number;
|
||||
compras_count: number;
|
||||
}
|
||||
|
||||
export interface PaqueteCompra {
|
||||
id: number;
|
||||
date: string | null;
|
||||
patient_id: number;
|
||||
patient: string;
|
||||
phone: string;
|
||||
paquete_id: number | null;
|
||||
paquete: string;
|
||||
service_id: number | null;
|
||||
service: string;
|
||||
sessions_total: number;
|
||||
sessions_used: number;
|
||||
sessions_remaining: number;
|
||||
price: number;
|
||||
expiry_date: string | null;
|
||||
state: 'active' | 'finished' | 'expired' | 'cancelled';
|
||||
venta_id: number | null;
|
||||
venta: string;
|
||||
user_login: string;
|
||||
}
|
||||
|
||||
export interface PaqueteConsumo {
|
||||
id: number;
|
||||
date: string | null;
|
||||
state: string;
|
||||
cita_id: number | null;
|
||||
cita_fecha: string | null;
|
||||
cita_hora: string;
|
||||
doctor: string;
|
||||
estado_cita: string;
|
||||
}
|
||||
|
||||
export interface PaquetePayload {
|
||||
name?: string;
|
||||
code?: string;
|
||||
service_id?: number;
|
||||
sessions?: number;
|
||||
price?: number;
|
||||
duration_min?: number;
|
||||
validity_days?: number;
|
||||
notes?: string;
|
||||
color?: string;
|
||||
genera_monedero?: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface MySaleItem {
|
||||
@@ -1115,7 +1185,7 @@ export interface AppNotification {
|
||||
|
||||
export interface PosCheckoutPayload {
|
||||
partner_id: number;
|
||||
lines: { service_id?: number; item_id?: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number }[];
|
||||
lines: { service_id?: number; item_id?: number; paquete_id?: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number }[];
|
||||
discount?: number;
|
||||
payment_method?: string;
|
||||
amount_received?: number;
|
||||
@@ -1975,6 +2045,60 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// ---------- Paquetes de sesiones (catálogo y saldos) ----------
|
||||
async getPaqueteCatalogo(params?: { search?: string; includeInactive?: boolean }): Promise<{ status: string; paquetes: Paquete[] }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/paquetes`, {
|
||||
params: {
|
||||
search: params?.search || undefined,
|
||||
include_inactive: params?.includeInactive ? '1' : undefined,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async createPaquete(payload: PaquetePayload): Promise<{ status: string; paquete: Paquete; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/paquetes`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updatePaquete(id: number, payload: PaquetePayload): Promise<{ status: string; paquete: Paquete; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/paquetes/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getPaqueteCompras(params?: {
|
||||
patient_id?: number; state?: string; search?: string; doctor_id?: number;
|
||||
start?: string; end?: string; includeAll?: boolean;
|
||||
}): Promise<{ status: string; compras: PaqueteCompra[]; totales: Record<string, number> }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/paquetes/compras`, {
|
||||
params: {
|
||||
patient_id: params?.patient_id || undefined,
|
||||
state: params?.state || undefined,
|
||||
search: params?.search || undefined,
|
||||
doctor_id: params?.doctor_id || undefined,
|
||||
start: params?.start || undefined,
|
||||
end: params?.end || undefined,
|
||||
include_all: params?.includeAll ? '1' : undefined,
|
||||
},
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async getPaqueteConsumos(id: number): Promise<{ status: string; compra: PaqueteCompra; consumos: PaqueteConsumo[]; message?: string }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/paquetes/compras/${id}/consumos`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async ajustarPaqueteCompra(id: number, payload: { sessions_total?: number; sessions_used?: number }): Promise<{ status: string; compra: PaqueteCompra; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/paquetes/compras/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async anularPaqueteCompra(id: number, reason: string): Promise<{ status: string; compra: PaqueteCompra; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/paquetes/compras/${id}/anular`, { reason });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getCashReportRange(start: string, end: string): Promise<{ status: string; total: number; by_method: Record<string, number> }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/cash`, { params: { start, end } });
|
||||
return data;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
'data/sequences.xml',
|
||||
'views/venta_views.xml',
|
||||
'views/corte_views.xml',
|
||||
'views/paquete_views.xml',
|
||||
'views/menu_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from . import tipo_cambio
|
||||
from . import pago
|
||||
from . import venta
|
||||
from . import paquete
|
||||
|
||||
279
odoo-addons/skeen_ventas/models/paquete.py
Normal file
279
odoo-addons/skeen_ventas/models/paquete.py
Normal file
@@ -0,0 +1,279 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Paquetes de sesiones.
|
||||
|
||||
El catálogo de paquetes vive en `skeen.paquete` (un paquete = N sesiones de un
|
||||
servicio a un precio cerrado). Cada vez que el POS vende uno se materializa una
|
||||
`skeen.paquete.compra` a nombre del paciente, que es la que lleva el saldo:
|
||||
|
||||
sessions_total sesiones contratadas
|
||||
sessions_used sesiones ya consumidas (citas atendidas)
|
||||
-> restantes = sessions_total - sessions_used
|
||||
|
||||
El consumo lo hace `SkeenCitaPaquete` (cita_package.py) al pasar la cita por
|
||||
'arrived'/'done', y lo revierte si la cita se cancela.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
# Estados de cita que ya gastaron una sesión del paquete
|
||||
CONSUME_STATES = ('arrived', 'in_progress', 'done')
|
||||
|
||||
|
||||
class SkeenPaquete(models.Model):
|
||||
_name = 'skeen.paquete'
|
||||
_description = 'Paquete de Sesiones'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(string='Nombre', required=True)
|
||||
code = fields.Char(string='Código')
|
||||
service_id = fields.Many2one('skeen.servicio', string='Servicio', required=True,
|
||||
help='Servicio cuyas sesiones incluye el paquete.')
|
||||
duration_min = fields.Integer(string='Duración sesión (min)',
|
||||
help='Solo informativo / para agendar. Si se deja '
|
||||
'en 0 se usa la del servicio.')
|
||||
sessions = fields.Integer(string='Sesiones', required=True, default=1)
|
||||
price = fields.Float(string='Precio paquete', required=True, default=0.0)
|
||||
validity_days = fields.Integer(string='Vigencia (días)', default=0,
|
||||
help='Días desde la compra para usar las sesiones. '
|
||||
'0 = sin caducidad.')
|
||||
notes = fields.Char(string='Notas')
|
||||
color = fields.Char(string='Color', default='#1abc9c')
|
||||
genera_monedero = fields.Boolean(string='Genera monedero', default=True)
|
||||
active = fields.Boolean(string='Activo', default=True)
|
||||
sequence = fields.Integer(string='Secuencia', default=10)
|
||||
|
||||
compra_ids = fields.One2many('skeen.paquete.compra', 'paquete_id', string='Compras')
|
||||
compras_count = fields.Integer(string='Compras', compute='_compute_compras_count')
|
||||
|
||||
@api.depends('compra_ids')
|
||||
def _compute_compras_count(self):
|
||||
for p in self:
|
||||
p.compras_count = len(p.compra_ids)
|
||||
|
||||
@api.constrains('sessions', 'price')
|
||||
def _check_sessions_price(self):
|
||||
for p in self:
|
||||
if p.sessions < 1:
|
||||
raise ValidationError(_('Un paquete debe tener al menos 1 sesión.'))
|
||||
if p.price < 0:
|
||||
raise ValidationError(_('El precio del paquete no puede ser negativo.'))
|
||||
|
||||
@api.onchange('service_id')
|
||||
def _onchange_service(self):
|
||||
if self.service_id and not self.duration_min:
|
||||
self.duration_min = self.service_id.duration_min
|
||||
|
||||
def name_get(self):
|
||||
return [(p.id, '%s (%d sesiones)' % (p.name, p.sessions)) for p in self]
|
||||
|
||||
|
||||
class SkeenPaqueteCompra(models.Model):
|
||||
_name = 'skeen.paquete.compra'
|
||||
_description = 'Paquete Comprado'
|
||||
_order = 'date desc, id desc'
|
||||
|
||||
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
||||
domain=[('is_patient', '=', True)])
|
||||
paquete_id = fields.Many2one('skeen.paquete', string='Paquete', required=True,
|
||||
ondelete='restrict')
|
||||
service_id = fields.Many2one(related='paquete_id.service_id', string='Servicio',
|
||||
store=True, readonly=True)
|
||||
venta_id = fields.Many2one('skeen.venta', string='Venta', ondelete='set null')
|
||||
sale_line_id = fields.Many2one('skeen.venta.line', string='Línea de venta',
|
||||
ondelete='set null')
|
||||
date = fields.Date(string='Fecha compra', required=True, default=fields.Date.today)
|
||||
sessions_total = fields.Integer(string='Sesiones', required=True, default=1)
|
||||
sessions_used = fields.Integer(string='Sesiones usadas', default=0)
|
||||
price = fields.Float(string='Precio pagado', default=0.0)
|
||||
user_login = fields.Char(string='Vendió', index=True)
|
||||
|
||||
sessions_remaining = fields.Integer(string='Restantes', compute='_compute_remaining',
|
||||
store=True)
|
||||
expiry_date = fields.Date(string='Vence', compute='_compute_expiry_date', store=True)
|
||||
state = fields.Selection([
|
||||
('active', 'Vigente'),
|
||||
('finished', 'Completado'),
|
||||
('expired', 'Vencido'),
|
||||
('cancelled', 'Anulado'),
|
||||
], string='Estado', default='active', compute='_compute_state', store=True)
|
||||
|
||||
line_ids = fields.One2many('skeen.paquete.consumo', 'compra_id', string='Consumos')
|
||||
consumo_count = fields.Integer(string='Consumos', compute='_compute_consumo_count')
|
||||
|
||||
@api.depends('sessions_total', 'sessions_used')
|
||||
def _compute_remaining(self):
|
||||
for c in self:
|
||||
c.sessions_remaining = max(0, c.sessions_total - c.sessions_used)
|
||||
|
||||
@api.depends('paquete_id.validity_days', 'date')
|
||||
def _compute_expiry_date(self):
|
||||
for c in self:
|
||||
days = c.paquete_id.validity_days or 0
|
||||
c.expiry_date = (c.date + timedelta(days=days)) \
|
||||
if (c.date and days > 0) else False
|
||||
|
||||
@api.depends('sessions_remaining', 'expiry_date', 'date')
|
||||
def _compute_state(self):
|
||||
today = fields.Date.today()
|
||||
for c in self:
|
||||
if c.sessions_total <= 0:
|
||||
c.state = 'cancelled'
|
||||
elif c.sessions_remaining <= 0:
|
||||
c.state = 'finished'
|
||||
elif c.expiry_date and c.expiry_date < today:
|
||||
c.state = 'expired'
|
||||
else:
|
||||
c.state = 'active'
|
||||
|
||||
@api.depends('line_ids')
|
||||
def _compute_consumo_count(self):
|
||||
for c in self:
|
||||
c.consumo_count = len(c.line_ids)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""sessions_total por defecto = las del paquete (el POS no siempre lo manda)."""
|
||||
for vals in vals_list:
|
||||
if not vals.get('sessions_total') and vals.get('paquete_id'):
|
||||
paquete = self.env['skeen.paquete'].browse(vals['paquete_id'])
|
||||
vals['sessions_total'] = paquete.sessions or 1
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_cancel(self):
|
||||
"""Anula el paquete: libera los consumos registrados."""
|
||||
for c in self:
|
||||
c.line_ids.write({'state': 'cancelled'})
|
||||
c.write({'sessions_used': 0})
|
||||
|
||||
def action_adjust_sessions(self, sessions_total=None, sessions_used=None):
|
||||
vals = {}
|
||||
if sessions_total is not None:
|
||||
vals['sessions_total'] = int(sessions_total)
|
||||
if sessions_used is not None:
|
||||
vals['sessions_used'] = int(sessions_used)
|
||||
if vals:
|
||||
self.write(vals)
|
||||
return True
|
||||
|
||||
|
||||
class SkeenPaqueteConsumo(models.Model):
|
||||
_name = 'skeen.paquete.consumo'
|
||||
_description = 'Consumo de Sesión de Paquete'
|
||||
_order = 'date desc, id desc'
|
||||
|
||||
compra_id = fields.Many2one('skeen.paquete.compra', string='Paquete comprado',
|
||||
required=True, ondelete='cascade')
|
||||
cita_id = fields.Many2one('skeen.cita', string='Cita', ondelete='set null', index=True)
|
||||
partner_id = fields.Many2one(related='compra_id.partner_id', string='Paciente',
|
||||
store=True, readonly=True)
|
||||
date = fields.Datetime(string='Fecha', default=fields.Datetime.now)
|
||||
state = fields.Selection([
|
||||
('consumed', 'Consumida'),
|
||||
('cancelled', 'Liberada'),
|
||||
], string='Estado', default='consumed')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_cita_compra', 'unique(cita_id, compra_id)',
|
||||
'Una misma cita solo puede consumir una sesión de un paquete.')
|
||||
]
|
||||
|
||||
|
||||
class SkeenVentaLinePaquete(models.Model):
|
||||
_inherit = 'skeen.venta.line'
|
||||
|
||||
paquete_id = fields.Many2one('skeen.paquete', string='Paquete',
|
||||
help='Si la línea vende un paquete de sesiones, '
|
||||
'aquí se enlaza. Al confirmar la venta se crea '
|
||||
'el saldo del paciente.')
|
||||
is_package = fields.Boolean(string='Es paquete', compute='_compute_is_package',
|
||||
store=True)
|
||||
|
||||
@api.depends('paquete_id')
|
||||
def _compute_is_package(self):
|
||||
for line in self:
|
||||
line.is_package = bool(line.paquete_id)
|
||||
|
||||
@api.constrains('paquete_id', 'service_id')
|
||||
def _check_paquete_service(self):
|
||||
for line in self:
|
||||
if line.paquete_id and line.service_id and \
|
||||
line.service_id != line.paquete_id.service_id:
|
||||
raise ValidationError(_(
|
||||
'La línea mezcla un paquete con un servicio distinto al del paquete.'
|
||||
))
|
||||
|
||||
|
||||
class SkeenCitaPaquete(models.Model):
|
||||
_inherit = 'skeen.cita'
|
||||
|
||||
paquete_compra_id = fields.Many2one('skeen.paquete.compra', string='Paquete origen',
|
||||
ondelete='set null', index=True,
|
||||
help='Compra de paquete que pagó esta cita.')
|
||||
|
||||
def _paquete_asignable(self):
|
||||
"""Compra vigente del paciente para el servicio de esta cita."""
|
||||
self.ensure_one()
|
||||
if not self.partner_id or not self.servicio_id:
|
||||
return None
|
||||
Compra = self.env['skeen.paquete.compra'].sudo()
|
||||
return Compra.search([
|
||||
('partner_id', '=', self.partner_id.id),
|
||||
('service_id', '=', self.servicio_id.id),
|
||||
('sessions_remaining', '>', 0),
|
||||
('state', '=', 'active'),
|
||||
], order='expiry_date asc, date asc', limit=1)
|
||||
|
||||
def _registrar_consumo(self, compra):
|
||||
for cita in self:
|
||||
existe = self.env['skeen.paquete.consumo'].sudo().search([
|
||||
('compra_id', '=', compra.id), ('cita_id', '=', cita.id)], limit=1)
|
||||
if existe:
|
||||
if existe.state != 'consumed':
|
||||
existe.write({'state': 'consumed'})
|
||||
compra.write({'sessions_used': compra.sessions_used + 1})
|
||||
continue
|
||||
self.env['skeen.paquete.consumo'].sudo().create({
|
||||
'compra_id': compra.id,
|
||||
'cita_id': cita.id,
|
||||
})
|
||||
compra.write({'sessions_used': compra.sessions_used + 1})
|
||||
if not cita.paquete_compra_id:
|
||||
cita.write({'paquete_compra_id': compra.id})
|
||||
|
||||
def _liberar_consumo(self):
|
||||
Consumos = self.env['skeen.paquete.consumo'].sudo()
|
||||
for cita in self:
|
||||
registro = Consumos.search([('cita_id', '=', cita.id),
|
||||
('state', '=', 'consumed')], limit=1)
|
||||
if not registro:
|
||||
continue
|
||||
compra = registro.compra_id
|
||||
registro.write({'state': 'cancelled'})
|
||||
compra.write({'sessions_used': max(0, compra.sessions_used - 1)})
|
||||
|
||||
def write(self, vals):
|
||||
cambio_estado = 'state' in vals
|
||||
antes = {c.id: c.state for c in self} if cambio_estado else {}
|
||||
res = super().write(vals)
|
||||
if not cambio_estado:
|
||||
return res
|
||||
Consumir, Liberar = self.browse(), self.browse()
|
||||
for c in self:
|
||||
previo = antes.get(c.id)
|
||||
if c.state in CONSUME_STATES and previo not in CONSUME_STATES:
|
||||
if not c.paquete_compra_id:
|
||||
compra = c._paquete_asignable()
|
||||
if compra:
|
||||
Consumir |= c
|
||||
elif c.state in ('cancelled', 'no_show', 'waitlist') and \
|
||||
previo in CONSUME_STATES:
|
||||
Liberar |= c
|
||||
for c in Consumir:
|
||||
compra = c._paquete_asignable()
|
||||
if compra:
|
||||
c._registrar_consumo(compra)
|
||||
Liberar._liberar_consumo()
|
||||
return res
|
||||
@@ -3,3 +3,6 @@ access_skeen_venta_user,Acceso a Ventas SKEEN,model_skeen_venta,base.group_user,
|
||||
access_skeen_venta_line_user,Acceso a Líneas de Venta SKEEN,model_skeen_venta_line,base.group_user,1,1,1,1
|
||||
access_skeen_corte_caja_user,Acceso a Cortes de Caja SKEEN,model_skeen_corte_caja,base.group_user,1,1,1,1
|
||||
access_skeen_tipo_cambio_user,Acceso a Tipo de Cambio SKEEN,model_skeen_tipo_cambio,base.group_user,1,1,1,1
|
||||
access_skeen_paquete_user,Acceso a Paquetes SKEEN,model_skeen_paquete,base.group_user,1,1,1,1
|
||||
access_skeen_paquete_compra_user,Acceso a Compras de Paquete SKEEN,model_skeen_paquete_compra,base.group_user,1,1,1,1
|
||||
access_skeen_paquete_consumo_user,Acceso a Consumos de Paquete SKEEN,model_skeen_paquete_consumo,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -4,5 +4,7 @@
|
||||
<menuitem id="menu_skeen_ventas_root" name="Ventas" parent="skeen_citas.menu_skeen_root" sequence="30" />
|
||||
<menuitem id="menu_skeen_venta" name="Ventas" parent="menu_skeen_ventas_root" action="action_skeen_venta" sequence="10" />
|
||||
<menuitem id="menu_skeen_corte_caja" name="Cortes de Caja" parent="menu_skeen_ventas_root" action="action_skeen_corte_caja" sequence="20" />
|
||||
<menuitem id="menu_skeen_paquete" name="Paquetes" parent="menu_skeen_ventas_root" action="action_skeen_paquete" sequence="30" />
|
||||
<menuitem id="menu_skeen_paquete_compra" name="Paquetes de Pacientes" parent="menu_skeen_ventas_root" action="action_skeen_paquete_compra" sequence="40" />
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
135
odoo-addons/skeen_ventas/views/paquete_views.xml
Normal file
135
odoo-addons/skeen_ventas/views/paquete_views.xml
Normal file
@@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- ============ Paquetes (catálogo) ============ -->
|
||||
<record id="view_skeen_paquete_tree" model="ir.ui.view">
|
||||
<field name="name">skeen.paquete.tree</field>
|
||||
<field name="model">skeen.paquete</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="sequence" widget="handle" />
|
||||
<field name="name" />
|
||||
<field name="service_id" />
|
||||
<field name="sessions" />
|
||||
<field name="price" />
|
||||
<field name="validity_days" />
|
||||
<field name="compras_count" />
|
||||
<field name="active" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_skeen_paquete_form" model="ir.ui.view">
|
||||
<field name="name">skeen.paquete.form</field>
|
||||
<field name="model">skeen.paquete</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="Ej. Lumecca paquete 3 sesiones" /></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="service_id" />
|
||||
<field name="sessions" />
|
||||
<field name="price" />
|
||||
<field name="duration_min" />
|
||||
</group>
|
||||
<group>
|
||||
<field name="code" />
|
||||
<field name="validity_days" />
|
||||
<field name="color" widget="color" />
|
||||
<field name="genera_monedero" />
|
||||
<field name="active" />
|
||||
<field name="sequence" />
|
||||
</group>
|
||||
</group>
|
||||
<field name="notes" placeholder="Notas del paquete" />
|
||||
<separator string="Compras" />
|
||||
<field name="compra_ids">
|
||||
<tree>
|
||||
<field name="date" />
|
||||
<field name="partner_id" />
|
||||
<field name="sessions_total" />
|
||||
<field name="sessions_used" />
|
||||
<field name="sessions_remaining" />
|
||||
<field name="expiry_date" />
|
||||
<field name="state" />
|
||||
</tree>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_skeen_paquete" model="ir.actions.act_window">
|
||||
<field name="name">Paquetes</field>
|
||||
<field name="res_model">skeen.paquete</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<!-- ============ Paquetes comprados (saldos) ============ -->
|
||||
<record id="view_skeen_paquete_compra_tree" model="ir.ui.view">
|
||||
<field name="name">skeen.paquete.compra.tree</field>
|
||||
<field name="model">skeen.paquete.compra</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-success="state=='active'" decoration-muted="state=='cancelled'"
|
||||
decoration-danger="state=='expired'" decoration-warning="state=='finished'">
|
||||
<field name="date" />
|
||||
<field name="partner_id" />
|
||||
<field name="paquete_id" />
|
||||
<field name="service_id" />
|
||||
<field name="sessions_total" />
|
||||
<field name="sessions_used" />
|
||||
<field name="sessions_remaining" />
|
||||
<field name="expiry_date" />
|
||||
<field name="price" />
|
||||
<field name="state" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_skeen_paquete_compra_form" model="ir.ui.view">
|
||||
<field name="name">skeen.paquete.compra.form</field>
|
||||
<field name="model">skeen.paquete.compra</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id" />
|
||||
<field name="paquete_id" />
|
||||
<field name="service_id" />
|
||||
<field name="date" />
|
||||
</group>
|
||||
<group>
|
||||
<field name="sessions_total" />
|
||||
<field name="sessions_used" />
|
||||
<field name="sessions_remaining" />
|
||||
<field name="expiry_date" />
|
||||
<field name="state" />
|
||||
<field name="venta_id" />
|
||||
<field name="user_login" />
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Sesiones consumidas" />
|
||||
<field name="line_ids">
|
||||
<tree>
|
||||
<field name="date" />
|
||||
<field name="cita_id" />
|
||||
<field name="state" />
|
||||
</tree>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_skeen_paquete_compra" model="ir.actions.act_window">
|
||||
<field name="name">Paquetes de Pacientes</field>
|
||||
<field name="res_model">skeen.paquete.compra</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -2114,13 +2114,52 @@ class SkeenFrontendController(http.Controller):
|
||||
def patient_packages(self, patient_id, **kw):
|
||||
"""Paquetes del paciente con sesiones restantes.
|
||||
|
||||
El sistema no guarda un contador de sesiones: el paquete se materializa
|
||||
como las citas creadas para un servicio con `package_sessions > 0`
|
||||
(create_appointment genera una cita por sesión). Por eso:
|
||||
total = citas no canceladas del paciente con ese servicio
|
||||
usadas = citas ya atendidas (llegó / en curso / completada)
|
||||
Fuente principal: `skeen.paquete.compra`, el saldo que crea el POS al
|
||||
vender un paquete y que la cita descuenta al atenderse.
|
||||
|
||||
De respaldo se mantiene la derivación antigua para pacientes migrados
|
||||
del sistema legacy, donde el paquete nunca se registró como compra y
|
||||
solo existe como las citas del servicio:
|
||||
total = citas no canceladas del paciente con ese servicio
|
||||
usadas = citas ya atendidas (llegó / en curso / completada)
|
||||
restantes = total - usadas
|
||||
"""
|
||||
try:
|
||||
Compras = request.env['skeen.paquete.compra'].sudo()
|
||||
compras = Compras.search([
|
||||
('partner_id', '=', patient_id),
|
||||
('state', 'in', ('active', 'finished', 'expired')),
|
||||
], order='date asc')
|
||||
reales = []
|
||||
servicios_con_compra = set()
|
||||
for c in compras:
|
||||
servicios_con_compra.add(c.service_id.id if c.service_id else None)
|
||||
reales.append({
|
||||
'id': c.id,
|
||||
'compra': True,
|
||||
'service_id': c.service_id.id if c.service_id else None,
|
||||
'name': c.paquete_id.name if c.paquete_id else (c.service_id.name if c.service_id else ''),
|
||||
'package_sessions': c.sessions_total,
|
||||
'total': c.sessions_total,
|
||||
'used': c.sessions_used,
|
||||
'remaining': c.sessions_remaining,
|
||||
'next_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
|
||||
'state': c.state,
|
||||
})
|
||||
if reales:
|
||||
heredados = self._paquetes_heredados(patient_id, servicios_con_compra)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'packages': sorted(reales + heredados,
|
||||
key=lambda r: (-r['remaining'], r['name'])),
|
||||
})
|
||||
return json_response({'status': 'success',
|
||||
'packages': self._paquetes_heredados(patient_id, set())})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _paquetes_heredados(self, patient_id, excluir_servicios):
|
||||
"""Saldos de paquete derivados de las citas (pacientes del legacy)."""
|
||||
try:
|
||||
Citas = request.env['skeen.cita'].sudo()
|
||||
citas = Citas.search([
|
||||
@@ -2130,6 +2169,8 @@ class SkeenFrontendController(http.Controller):
|
||||
])
|
||||
grupos = {}
|
||||
for c in citas:
|
||||
if excluir_servicios and c.servicio_id.id in excluir_servicios:
|
||||
continue
|
||||
g = grupos.setdefault(c.servicio_id.id, {
|
||||
'service_id': c.servicio_id.id,
|
||||
'name': c.servicio_id.name,
|
||||
@@ -2146,12 +2187,288 @@ class SkeenFrontendController(http.Controller):
|
||||
rows = []
|
||||
for g in grupos.values():
|
||||
g['remaining'] = max(0, g['total'] - g['used'])
|
||||
g['compra'] = False
|
||||
g['id'] = None
|
||||
g['state'] = 'active' if g['remaining'] else 'finished'
|
||||
rows.append(g)
|
||||
rows.sort(key=lambda r: (-r['remaining'], r['name']))
|
||||
return json_response({'status': 'success', 'packages': rows})
|
||||
return rows
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# Paquetes de sesiones
|
||||
# ============================================================
|
||||
def _paquete_to_dict(self, p):
|
||||
servicio = p.service_id
|
||||
return {
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
'code': p.code or '',
|
||||
'service_id': servicio.id if servicio else None,
|
||||
'service': servicio.name if servicio else '',
|
||||
'sessions': p.sessions,
|
||||
'price': p.price,
|
||||
'price_per_session': round(p.price / p.sessions, 2) if p.sessions else 0.0,
|
||||
'session_price': servicio.price if servicio else 0.0,
|
||||
'duration_min': p.duration_min or (servicio.duration_min if servicio else 0),
|
||||
'validity_days': p.validity_days or 0,
|
||||
'notes': p.notes or '',
|
||||
'color': p.color or '#1abc9c',
|
||||
'genera_monedero': p.genera_monedero,
|
||||
'active': p.active,
|
||||
'sequence': p.sequence or 0,
|
||||
'compras_count': p.compras_count,
|
||||
}
|
||||
|
||||
def _paquete_compra_to_dict(self, c):
|
||||
return {
|
||||
'id': c.id,
|
||||
'date': c.date.strftime('%Y-%m-%d') if c.date else None,
|
||||
'patient_id': c.partner_id.id,
|
||||
'patient': c.partner_id.name or '',
|
||||
'phone': c.partner_id.phone or '',
|
||||
'paquete_id': c.paquete_id.id if c.paquete_id else None,
|
||||
'paquete': c.paquete_id.name if c.paquete_id else '',
|
||||
'service_id': c.service_id.id if c.service_id else None,
|
||||
'service': c.service_id.name if c.service_id else '',
|
||||
'sessions_total': c.sessions_total,
|
||||
'sessions_used': c.sessions_used,
|
||||
'sessions_remaining': c.sessions_remaining,
|
||||
'price': c.price,
|
||||
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
|
||||
'state': c.state,
|
||||
'venta_id': c.venta_id.id if c.venta_id else None,
|
||||
'venta': c.venta_id.name if c.venta_id else '',
|
||||
'user_login': c.user_login or '',
|
||||
}
|
||||
|
||||
@http.route('/skeen/frontend/v1/paquetes', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def list_paquetes(self, **kw):
|
||||
"""Catálogo de paquetes. `include_inactive=1` para ver los dados de baja."""
|
||||
try:
|
||||
domain = []
|
||||
if not (kw.get('include_inactive') or '').strip():
|
||||
domain.append(('active', '=', True))
|
||||
if kw.get('search'):
|
||||
s = kw['search']
|
||||
domain += ['|', ('name', 'ilike', s), ('code', 'ilike', s)]
|
||||
if kw.get('service_id'):
|
||||
domain.append(('service_id', '=', int(kw['service_id'])))
|
||||
rows = request.env['skeen.paquete'].sudo().with_context(
|
||||
active_test=False).search(domain, order='sequence asc, name asc')
|
||||
return json_response({'status': 'success', 'paquetes': [
|
||||
self._paquete_to_dict(p) for p in rows]})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/paquetes', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@require_role('admin')
|
||||
def create_paquete(self, **kw):
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'El nombre es obligatorio'}, 400)
|
||||
if not data.get('service_id'):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'El servicio es obligatorio'}, 400)
|
||||
Paq = request.env['skeen.paquete'].sudo()
|
||||
if Paq.search([('name', '=ilike', name)], limit=1):
|
||||
return json_response({'status': 'error',
|
||||
'message': f'Ya existe un paquete "{name}".'}, 409)
|
||||
vals = {
|
||||
'name': name,
|
||||
'code': (data.get('code') or '').strip(),
|
||||
'service_id': int(data['service_id']),
|
||||
'sessions': int(data.get('sessions') or 1),
|
||||
'price': float(data.get('price') or 0.0),
|
||||
'duration_min': int(data.get('duration_min') or 0),
|
||||
'validity_days': int(data.get('validity_days') or 0),
|
||||
'notes': data.get('notes') or '',
|
||||
'color': data.get('color') or '#1abc9c',
|
||||
'genera_monedero': bool(data.get('genera_monedero', True)),
|
||||
}
|
||||
try:
|
||||
p = Paq.create(vals)
|
||||
except ValidationError as e:
|
||||
return json_response({'status': 'error',
|
||||
'message': e.args[0] if e.args else str(e)}, 400)
|
||||
self._auditar('paquetes', 'skeen.paquete', 'create',
|
||||
new_value=p.name, entity_label=p.name,
|
||||
notes=f'{p.sessions} sesiones · {p.price}')
|
||||
return json_response({'status': 'success', 'paquete': self._paquete_to_dict(p)}, 201)
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/paquetes/<int:paquete_id>', type='http', auth='none',
|
||||
methods=['PUT', 'PATCH'], csrf=False)
|
||||
@require_role('admin')
|
||||
def update_paquete(self, paquete_id, **kw):
|
||||
try:
|
||||
p = request.env['skeen.paquete'].sudo().with_context(active_test=False).browse(paquete_id)
|
||||
if not p.exists():
|
||||
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
|
||||
data = _parse_json_body()
|
||||
mapping = {
|
||||
'name': 'name', 'code': 'code', 'notes': 'notes', 'color': 'color',
|
||||
}
|
||||
vals = {}
|
||||
for key, field in mapping.items():
|
||||
if key in data:
|
||||
vals[field] = (data[key] or '').strip() if field != 'notes' else (data[key] or '')
|
||||
if 'service_id' in data and data['service_id']:
|
||||
vals['service_id'] = int(data['service_id'])
|
||||
for key in ('sessions', 'duration_min', 'validity_days', 'sequence'):
|
||||
if key in data:
|
||||
vals[key] = int(data[key] or 0)
|
||||
for key in ('price',):
|
||||
if key in data:
|
||||
vals[key] = float(data[key] or 0.0)
|
||||
for key in ('genera_monedero', 'active'):
|
||||
if key in data:
|
||||
vals[key] = bool(data[key])
|
||||
if 'active' in vals and vals['active'] != p.active:
|
||||
self._auditar('paquetes', 'skeen.paquete', 'active',
|
||||
old_value='activo' if p.active else 'inactivo',
|
||||
new_value='activo' if vals['active'] else 'inactivo',
|
||||
entity_label=p.name,
|
||||
notes='Alta de baja' if not vals['active'] else 'Reactivación')
|
||||
try:
|
||||
p.write(vals)
|
||||
except ValidationError as e:
|
||||
return json_response({'status': 'error',
|
||||
'message': e.args[0] if e.args else str(e)}, 400)
|
||||
self._auditar('paquetes', 'skeen.paquete', 'edit',
|
||||
new_value=', '.join(vals.keys()), entity_label=p.name)
|
||||
return json_response({'status': 'success', 'paquete': self._paquete_to_dict(p)})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/paquetes/compras', type='http', auth='none',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def list_paquetes_compras(self, **kw):
|
||||
"""Paquetes vendidos con su saldo.
|
||||
|
||||
Filtros: patient_id, state (active|finished|expired|cancelled),
|
||||
doctor_id, search (paciente o paquete), start/end por fecha de compra.
|
||||
"""
|
||||
try:
|
||||
domain = []
|
||||
if kw.get('patient_id'):
|
||||
domain.append(('partner_id', '=', int(kw['patient_id'])))
|
||||
if kw.get('state'):
|
||||
domain.append(('state', '=', kw['state']))
|
||||
elif not (kw.get('include_all') or '').strip():
|
||||
domain.append(('state', '!=', 'cancelled'))
|
||||
if kw.get('search'):
|
||||
s = f"%{kw['search']}%"
|
||||
domain += ['|', ('partner_id.name', 'ilike', s),
|
||||
('paquete_id.name', 'ilike', s)]
|
||||
if kw.get('start'):
|
||||
domain.append(('date', '>=', kw['start']))
|
||||
if kw.get('end'):
|
||||
domain.append(('date', '<=', kw['end']))
|
||||
if kw.get('doctor_id'):
|
||||
domain.append(('line_ids.cita_id.doctor_id', '=', int(kw['doctor_id'])))
|
||||
limit = min(max(int(kw.get('limit', 300) or 300), 1), 1000)
|
||||
rows = request.env['skeen.paquete.compra'].sudo().search(
|
||||
domain, order='date desc, id desc', limit=limit)
|
||||
totales = {
|
||||
'vigentes': sum(1 for c in rows if c.state == 'active'),
|
||||
'completados': sum(1 for c in rows if c.state == 'finished'),
|
||||
'vencidos': sum(1 for c in rows if c.state == 'expired'),
|
||||
'sesiones_restantes': sum(c.sessions_remaining for c in rows
|
||||
if c.state == 'active'),
|
||||
'importe': sum(c.price for c in rows),
|
||||
}
|
||||
return json_response({'status': 'success',
|
||||
'compras': [self._paquete_compra_to_dict(c) for c in rows],
|
||||
'totales': totales})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>/consumos',
|
||||
type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def paquete_compra_consumos(self, compra_id, **kw):
|
||||
"""Bitácora de sesiones consumidas por un paquete (con su cita)."""
|
||||
try:
|
||||
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
|
||||
if not c.exists():
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Paquete no encontrado'}, 404)
|
||||
filas = []
|
||||
for mov in c.line_ids:
|
||||
cita = mov.cita_id
|
||||
filas.append({
|
||||
'id': mov.id,
|
||||
'date': mov.date.strftime('%Y-%m-%d %H:%M') if mov.date else None,
|
||||
'state': mov.state,
|
||||
'cita_id': cita.id if cita else None,
|
||||
'cita_fecha': cita.date.strftime('%Y-%m-%d') if (cita and cita.date) else None,
|
||||
'cita_hora': cita.time_str if cita else '',
|
||||
'doctor': cita.doctor_id.name if (cita and cita.doctor_id) else '',
|
||||
'estado_cita': cita.state if cita else '',
|
||||
})
|
||||
filas.sort(key=lambda f: (f['cita_fecha'] or '', f['cita_hora'] or ''))
|
||||
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c),
|
||||
'consumos': filas})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>', type='http',
|
||||
auth='none', methods=['PUT', 'PATCH'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
def update_paquete_compra(self, compra_id, **kw):
|
||||
"""Ajuste manual del saldo (sesiones compradas / usadas)."""
|
||||
try:
|
||||
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
|
||||
if not c.exists():
|
||||
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
|
||||
data = _parse_json_body()
|
||||
before = f"{c.sessions_total}/{c.sessions_used}"
|
||||
try:
|
||||
c.action_adjust_sessions(data.get('sessions_total'), data.get('sessions_used'))
|
||||
except (ValueError, TypeError):
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Las sesiones deben ser números enteros'}, 400)
|
||||
self._auditar('paquetes', 'skeen.paquete.compra', 'sessions',
|
||||
old_value=before,
|
||||
new_value=f"{c.sessions_total}/{c.sessions_used}",
|
||||
entity_label=f'{c.partner_id.name} · {c.paquete_id.name}',
|
||||
notes='Ajuste manual de saldo')
|
||||
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c)})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>/anular',
|
||||
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('gerencia')
|
||||
def anular_paquete_compra(self, compra_id, **kw):
|
||||
try:
|
||||
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
|
||||
if not c.exists():
|
||||
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
|
||||
data = _parse_json_body()
|
||||
motivo = (data.get('reason') or '').strip()
|
||||
if not motivo:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Indica el motivo de la anulación'}, 400)
|
||||
c.action_cancel()
|
||||
c.write({'sessions_total': 0})
|
||||
self._auditar('paquetes', 'skeen.paquete.compra', 'state',
|
||||
old_value='activo', new_value='anulado',
|
||||
entity_label=f'{c.partner_id.name} · {c.paquete_id.name}',
|
||||
notes=motivo)
|
||||
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c)})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
# ============================================================
|
||||
# Adeudos: cobro con recibo y plan de pagos
|
||||
# ============================================================
|
||||
@@ -4270,6 +4587,7 @@ class SkeenFrontendController(http.Controller):
|
||||
discount = float(data.get('discount', 0) or 0)
|
||||
line_vals = []
|
||||
item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar
|
||||
paquete_lines = [] # (paquete_id, qty, price): materializan el saldo del paciente
|
||||
stock_warnings = []
|
||||
subtotal = 0.0
|
||||
for l in lines:
|
||||
@@ -4279,6 +4597,12 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'error', 'message': 'Cantidad inválida en una línea'}, 400)
|
||||
service_id = int(l.get('service_id', 0) or 0)
|
||||
item_id = int(l.get('item_id', 0) or 0)
|
||||
paquete_id = int(l.get('paquete_id', 0) or 0)
|
||||
if paquete_id and not service_id:
|
||||
# Una línea de paquete también consume servicio (el del paquete)
|
||||
# para que el reporte de comisiones no se rompa.
|
||||
service_id = int(request.env['skeen.paquete'].sudo().browse(
|
||||
paquete_id).service_id.id or 0)
|
||||
if bool(service_id) == bool(item_id):
|
||||
return json_response({'status': 'error', 'message': 'Cada línea debe tener un servicio o un artículo'}, 400)
|
||||
subtotal += qty * price
|
||||
@@ -4308,7 +4632,10 @@ class SkeenFrontendController(http.Controller):
|
||||
'price_unit': price,
|
||||
'is_prescribed': bool(l.get('prescribed_by_id')),
|
||||
'prescribed_by_id': l.get('prescribed_by_id') or False,
|
||||
'paquete_id': paquete_id or False,
|
||||
}))
|
||||
if paquete_id:
|
||||
paquete_lines.append((paquete_id, qty, price))
|
||||
total_est = max(0.0, subtotal - discount)
|
||||
|
||||
# Moneda y tipo de cambio: validar ANTES de crear nada
|
||||
@@ -4343,6 +4670,25 @@ class SkeenFrontendController(http.Controller):
|
||||
})
|
||||
venta.action_confirm()
|
||||
|
||||
# Paquetes vendidos: materializar el saldo del paciente. Se enlazan
|
||||
# las compras a las líneas reales de la venta para poder revertirlas.
|
||||
compras_creadas = []
|
||||
if paquete_lines:
|
||||
Compra = request.env['skeen.paquete.compra'].sudo()
|
||||
lineas_venta = venta.line_ids.filtered(lambda ln: ln.paquete_id)
|
||||
for line in lineas_venta:
|
||||
for _i in range(int(line.quantity or 1)):
|
||||
compras_creadas.append(Compra.create({
|
||||
'partner_id': partner.id,
|
||||
'paquete_id': line.paquete_id.id,
|
||||
'venta_id': venta.id,
|
||||
'sale_line_id': line.id,
|
||||
'date': venta.date or fields.Date.today(),
|
||||
'sessions_total': line.paquete_id.sessions,
|
||||
'price': line.price_unit,
|
||||
'user_login': cobrador,
|
||||
}))
|
||||
|
||||
# Descargo de inventario por cada artículo (el create del move ajusta el stock)
|
||||
if item_lines:
|
||||
Move = request.env['skeen.inventario.move'].sudo()
|
||||
@@ -4425,6 +4771,14 @@ class SkeenFrontendController(http.Controller):
|
||||
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
|
||||
'wallet_points': partner.wallet_points,
|
||||
'stock_warnings': stock_warnings,
|
||||
'packages': [{
|
||||
'id': c.id,
|
||||
'paquete': c.paquete_id.name,
|
||||
'service_id': c.service_id.id if c.service_id else None,
|
||||
'sessions_total': c.sessions_total,
|
||||
'sessions_remaining': c.sessions_remaining,
|
||||
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
|
||||
} for c in compras_creadas],
|
||||
}, 201)
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@@ -29,6 +29,12 @@ class SkeenConfigAudit(models.Model):
|
||||
('pacientes', 'Expedientes de pacientes'),
|
||||
('catalogos', 'Catálogos'),
|
||||
('clinica', 'Clínica'),
|
||||
# 'agenda' (bloqueos y lista de espera) y 'paquetes' se escriben desde
|
||||
# los endpoints del frontend; al no estar en la lista, Odoo rechazaba el
|
||||
# create y el _auditar() lo silenciaba, perdiendo esos registros.
|
||||
('agenda', 'Agenda'),
|
||||
('paquetes', 'Paquetes'),
|
||||
('monedero', 'Monedero'),
|
||||
], string='Módulo', required=True, index=True)
|
||||
|
||||
entity = fields.Char(string='Entidad', index=True,
|
||||
|
||||
@@ -58,6 +58,7 @@ DEFAULT_MENU_ROLES = {
|
||||
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
|
||||
'/medicos': ['admin', 'recepcion', 'gerencia'],
|
||||
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||
'/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/ventas': ['admin', 'recepcion', 'gerencia'],
|
||||
'/tickets': ['admin', 'gerencia'],
|
||||
@@ -115,12 +116,20 @@ def default_menu_roles():
|
||||
|
||||
|
||||
def get_menu_roles(env):
|
||||
"""Matriz vigente: la guardada en ir.config_parameter o los defaults."""
|
||||
"""Matriz vigente: la guardada en ir.config_parameter o los defaults.
|
||||
|
||||
La matriz guardada manda sobre los defaults, PERO se completan en memoria
|
||||
las rutas que no existan en ella. Sin eso, una ruta nueva (p. ej.
|
||||
'/paquetes') no aparecería en la matriz guardada y el frontend la trataría
|
||||
como "visible para todos" por no matchear ninguna llave.
|
||||
"""
|
||||
raw = env['ir.config_parameter'].sudo().get_param(MENU_ROLES_KEY)
|
||||
if raw:
|
||||
try:
|
||||
val = json.loads(raw)
|
||||
if isinstance(val, dict):
|
||||
for href, roles in default_menu_roles().items():
|
||||
val.setdefault(href, roles)
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user