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;
|
||||
|
||||
Reference in New Issue
Block a user