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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user