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:
2026-09-24 17:17:29 +00:00
parent 09b4f25bb8
commit dcf2c367f1
18 changed files with 1892 additions and 17 deletions

View File

@@ -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>} />

View File

@@ -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;

View File

@@ -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: [

View File

@@ -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' },
],
},
{

View File

@@ -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' },
],
},
{

View File

@@ -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'],

View 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;

View File

@@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package,
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List,
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">

View File

@@ -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;