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

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