Oleada UX 3: POS (repetir ultima venta, aviso de paquetes, atajos, caja desde POS), Ventas (cancelacion auditada con reversion de monedero y stock, detalle inline, presets de fecha), Cortes (historial comparativo y export)
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
toast,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor } from '../services/odoo';
|
||||
import type { PatientPackage } from '../services/odoo';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||||
@@ -86,6 +88,19 @@ const Pos: FC = () => {
|
||||
|
||||
// Corte de caja
|
||||
const [cajaAbierta, setCajaAbierta] = useState(true);
|
||||
const [cajaModalOpen, setCajaModalOpen] = useState(false);
|
||||
const [cajaFondo, setCajaFondo] = useState('');
|
||||
const [cajaAbriendo, setCajaAbriendo] = useState(false);
|
||||
|
||||
// Repetir última venta
|
||||
const [lastSaleLoading, setLastSaleLoading] = useState(false);
|
||||
// Paquetes del paciente con sesiones pendientes
|
||||
const [paquetes, setPaquetes] = useState<PatientPackage[]>([]);
|
||||
|
||||
// Atajos de teclado
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [ticketFocused, setTicketFocused] = useState(false);
|
||||
const searchWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Mobile
|
||||
const [ticketMovil, setTicketMovil] = useState(false);
|
||||
@@ -358,16 +373,139 @@ const Pos: FC = () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Al seleccionar paciente: traer su adeudo para el badge de alerta en caja
|
||||
// Al seleccionar paciente: traer su adeudo y sus paquetes con sesiones pendientes
|
||||
const selectPaciente = (p: Patient) => {
|
||||
setPaciente(p);
|
||||
setPacienteResults([]);
|
||||
setPacienteAdeudo(0);
|
||||
setPaquetes([]);
|
||||
odooApi.getPatientHistory(p.id)
|
||||
.then((res) => { if (res.status === 'success') setPacienteAdeudo(res.estado_cuenta?.adeudo ?? 0); })
|
||||
.catch(() => {});
|
||||
odooApi.getPatientPackages(p.id)
|
||||
.then((res) => { if (res.status === 'success') setPaquetes(res.packages.filter((k) => k.remaining > 0)); })
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
// ---- Repetir última venta (caso típico: toxina cada 3 meses) ----
|
||||
const repetirUltimaVenta = async () => {
|
||||
if (!paciente) return;
|
||||
if (lines.length > 0 && !window.confirm('El ticket ya tiene líneas. ¿Reemplazarlas por las de la última venta?')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLastSaleLoading(true);
|
||||
const res = await odooApi.getLastSale(paciente.id);
|
||||
if (res.status !== 'success' || !res.sale) {
|
||||
toast.error('Este paciente no tiene ventas previas');
|
||||
return;
|
||||
}
|
||||
// Se rearma con el precio VIGENTE del catálogo y se omiten los artículos
|
||||
// que ya no existen o están inactivos (se avisan).
|
||||
const nuevas: TicketLine[] = [];
|
||||
const omitidas: string[] = [];
|
||||
for (const l of res.lines) {
|
||||
if (l.missing) { omitidas.push(l.name); continue; }
|
||||
const key = l.kind === 'service' ? `s-${l.service_id}` : `i-${l.item_id}`;
|
||||
const found = nuevas.find((x) => x.key === key);
|
||||
if (found) { found.qty += l.quantity; continue; }
|
||||
nuevas.push({
|
||||
key,
|
||||
name: l.name,
|
||||
qty: l.quantity,
|
||||
price: l.price_current ?? l.price_last,
|
||||
...(l.kind === 'service' ? { service_id: l.service_id ?? undefined } : {}),
|
||||
...(l.kind === 'item' ? { item_id: l.item_id ?? undefined, stock: l.stock ?? undefined, unit: l.unit ?? undefined } : {}),
|
||||
});
|
||||
}
|
||||
setLines(nuevas);
|
||||
const cambiados = res.lines.filter((l) => l.price_changed && !l.missing).length;
|
||||
toast.success(`Ticket cargado desde ${res.sale.name} (${res.sale.date})`);
|
||||
res.warnings.forEach((w) => toast.warning(w));
|
||||
if (omitidas.length > 0 && cambiados === 0) {
|
||||
toast.warning(`No se agregaron: ${omitidas.join(', ')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('No se pudo cargar la última venta');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLastSaleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Caja: abrir desde el POS sin perder el ticket ----
|
||||
const abrirCajaDesdePos = async () => {
|
||||
const fondo = parseFloat(cajaFondo || '0');
|
||||
if (Number.isNaN(fondo) || fondo < 0) {
|
||||
toast.error('Ingresa un fondo de caja válido');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setCajaAbriendo(true);
|
||||
await odooApi.createCashClosing({ date: new Date().toISOString().split('T')[0], opening_cash: fondo });
|
||||
setCajaAbierta(true);
|
||||
setCajaModalOpen(false);
|
||||
setCajaFondo('');
|
||||
toast.success('Caja abierta');
|
||||
} catch (err) {
|
||||
toast.error('No se pudo abrir la caja');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setCajaAbriendo(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Atajos de teclado ----
|
||||
// F2 buscador de servicios · F9 cobrar · +/- cantidad de la última línea
|
||||
// (con el ticket enfocado) · Esc cierra el modal abierto (lo maneja Modal).
|
||||
const ajustarUltimaLinea = (delta: number) => {
|
||||
setLines((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const last = prev[prev.length - 1];
|
||||
const qty = Math.max(1, last.qty + delta);
|
||||
return prev.map((l, i) => (i === prev.length - 1 ? { ...l, qty } : l));
|
||||
});
|
||||
};
|
||||
|
||||
const cobrarConAtajo = () => {
|
||||
if (lines.length === 0 || !paciente) {
|
||||
toast.error(lines.length === 0 ? 'El ticket está vacío' : 'Selecciona un paciente');
|
||||
return;
|
||||
}
|
||||
abrirCobro();
|
||||
};
|
||||
|
||||
const hotkeyRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
||||
hotkeyRef.current = (e: KeyboardEvent) => {
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
searchWrapRef.current?.querySelector('input')?.focus();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'F9') {
|
||||
e.preventDefault();
|
||||
cobrarConAtajo();
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return;
|
||||
if (!ticketFocused) return;
|
||||
if (e.key === '+' || e.key === '=') {
|
||||
e.preventDefault();
|
||||
ajustarUltimaLinea(1);
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
e.preventDefault();
|
||||
ajustarUltimaLinea(-1);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => hotkeyRef.current(e);
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
const nuevaVenta = () => {
|
||||
setResultado(null);
|
||||
setLines([]);
|
||||
@@ -376,6 +514,7 @@ const Pos: FC = () => {
|
||||
setPacienteAdeudo(0);
|
||||
setPacienteQuery('');
|
||||
setDoctorId('');
|
||||
setPaquetes([]);
|
||||
};
|
||||
|
||||
// ---- Bloques del ticket (desktop sticky y modal móvil) ----
|
||||
@@ -423,6 +562,34 @@ const Pos: FC = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Paquetes con sesiones pendientes: aviso informativo para caja.
|
||||
No hay consumo automático de sesión (el sistema no guarda contador):
|
||||
las sesiones restantes se derivan de las citas del paquete. */}
|
||||
{paquetes.length > 0 && (
|
||||
<div className="rounded-lg border border-violet-300 bg-violet-50 px-2.5 py-1.5 space-y-0.5">
|
||||
{paquetes.map((k) => (
|
||||
<p key={k.service_id} className="text-[11px] text-violet-800 flex items-center gap-1.5">
|
||||
<Package size={11} className="shrink-0" />
|
||||
<span>
|
||||
Paquete <span className="font-medium">{k.name}</span>: {k.remaining} de {k.total} sesiones restantes
|
||||
{k.next_date ? ` · próxima ${k.next_date}` : ''}
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Repetir la última venta del paciente (toxina cada 3 meses, etc.) */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={repetirUltimaVenta}
|
||||
loading={lastSaleLoading}
|
||||
title="Carga las líneas de la última venta con los precios actuales del catálogo"
|
||||
>
|
||||
<RotateCcw size={13} className="mr-1.5" />
|
||||
Repetir última venta
|
||||
</Button>
|
||||
</div>
|
||||
) : altaRapida ? (
|
||||
<div className="p-2.5 bg-theme-bg rounded-xl space-y-2">
|
||||
@@ -564,22 +731,45 @@ const Pos: FC = () => {
|
||||
className="w-full mt-1 !py-3 text-base"
|
||||
onClick={abrirCobro}
|
||||
disabled={lines.length === 0 || !paciente}
|
||||
title="Cobrar (F9)"
|
||||
>
|
||||
<ShoppingBag size={18} className="mr-2" />
|
||||
Cobrar {fmtMoney(total)}
|
||||
</Button>
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-theme-muted">
|
||||
<Keyboard size={11} aria-hidden="true" />
|
||||
F2 buscar · F9 cobrar · +/− cantidad · Esc cerrar
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShortcutsOpen(true)}
|
||||
className="underline hover:text-theme-heading"
|
||||
>
|
||||
Ver atajos
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout title="Punto de Venta" subtitle="Arma el ticket y cobra en un solo paso">
|
||||
{!cajaAbierta && (
|
||||
<div className="mb-3">
|
||||
{!cajaAbierta ? (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-theme-warning text-theme-warning-text text-xs">
|
||||
<AlertTriangle size={13} className="shrink-0" />
|
||||
Sin corte de caja abierto hoy
|
||||
<Link to="/cortes" className="font-semibold underline">Abrir caja</Link>
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setCajaModalOpen(true)}>
|
||||
<Lock size={13} className="mr-1.5" />
|
||||
Abrir caja aquí
|
||||
</Button>
|
||||
<Link to="/cortes" className="text-xs font-semibold underline text-theme-heading">Ir a Cortes de caja</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3 flex items-center gap-2 text-xs text-theme-muted">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-theme-bg text-xs">
|
||||
Caja abierta
|
||||
</span>
|
||||
<Link to="/cortes" className="underline hover:text-theme-heading">Cerrar caja</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -587,10 +777,10 @@ const Pos: FC = () => {
|
||||
{/* Catálogo */}
|
||||
<Card className="flex-1 min-w-0 w-full">
|
||||
<Card.Body>
|
||||
<div className="relative mb-3">
|
||||
<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...' : 'Buscar artículo...'}
|
||||
placeholder={catMode === 'servicios' ? 'Buscar servicio... (F2)' : 'Buscar artículo... (F2)'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
@@ -702,24 +892,33 @@ const Pos: FC = () => {
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Ticket desktop: panel sticky full-height */}
|
||||
{/* Ticket desktop: panel sticky full-height. El foco en este panel
|
||||
habilita +/− para la cantidad de la última línea. */}
|
||||
<Card className="hidden lg:flex lg:flex-col w-[390px] shrink-0 sticky top-6 max-h-[calc(100vh-7rem)]">
|
||||
<div className="p-4 pb-3 border-b border-theme-border shrink-0">
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<h3 className="font-heading text-lg text-theme-heading">Ticket</h3>
|
||||
{lines.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={limpiarTicket} title="Vaciar ticket">
|
||||
<Trash2 size={15} className="text-theme-muted" />
|
||||
</Button>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col flex-1 min-h-0 outline-none"
|
||||
tabIndex={0}
|
||||
onFocusCapture={() => setTicketFocused(true)}
|
||||
onBlurCapture={() => setTicketFocused(false)}
|
||||
title="Atajos: + y − ajustan la cantidad de la última línea cuando el ticket tiene el foco"
|
||||
>
|
||||
<div className="p-4 pb-3 border-b border-theme-border shrink-0">
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<h3 className="font-heading text-lg text-theme-heading">Ticket</h3>
|
||||
{lines.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={limpiarTicket} title="Vaciar ticket">
|
||||
<Trash2 size={15} className="text-theme-muted" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{pacienteBlock}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-1">
|
||||
{linesBlock}
|
||||
</div>
|
||||
<div className="p-4 pt-3 border-t border-theme-border shrink-0 space-y-2">
|
||||
{totalsBlock}
|
||||
</div>
|
||||
{pacienteBlock}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-1">
|
||||
{linesBlock}
|
||||
</div>
|
||||
<div className="p-4 pt-3 border-t border-theme-border shrink-0 space-y-2">
|
||||
{totalsBlock}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -737,13 +936,70 @@ const Pos: FC = () => {
|
||||
|
||||
{/* Ticket móvil */}
|
||||
<Modal isOpen={ticketMovil} onClose={() => setTicketMovil(false)} title="Ticket" maxWidth="md">
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="space-y-3"
|
||||
tabIndex={-1}
|
||||
onFocusCapture={() => setTicketFocused(true)}
|
||||
onBlurCapture={() => setTicketFocused(false)}
|
||||
>
|
||||
{pacienteBlock}
|
||||
<div className="max-h-[38vh] overflow-y-auto">{linesBlock}</div>
|
||||
<div className="border-t border-theme-border pt-3 space-y-2">{totalsBlock}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Ayuda discreta de atajos de teclado */}
|
||||
<Modal
|
||||
isOpen={shortcutsOpen}
|
||||
onClose={() => setShortcutsOpen(false)}
|
||||
title="Atajos de teclado"
|
||||
maxWidth="sm"
|
||||
footer={<Button variant="outline" onClick={() => setShortcutsOpen(false)}>Cerrar</Button>}
|
||||
>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{[
|
||||
{ k: 'F2', d: 'Ir al buscador de servicios/artículos' },
|
||||
{ k: 'F9', d: 'Cobrar (abre el modal de cobro si hay líneas)' },
|
||||
{ k: '+ / −', d: 'Cantidad de la última línea (con el ticket enfocado)' },
|
||||
{ k: 'Esc', d: 'Cerrar el modal abierto' },
|
||||
].map((s) => (
|
||||
<li key={s.k} className="flex items-start gap-3">
|
||||
<span className="shrink-0 rounded-md border border-theme-border-strong bg-theme-bg px-2 py-0.5 font-mono text-xs text-theme-heading">{s.k}</span>
|
||||
<span className="text-theme-muted">{s.d}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
|
||||
{/* Abrir caja sin salir del POS */}
|
||||
<Modal
|
||||
isOpen={cajaModalOpen}
|
||||
onClose={() => setCajaModalOpen(false)}
|
||||
title="Abrir caja"
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCajaModalOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={abrirCajaDesdePos} loading={cajaAbriendo}>Abrir caja</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-theme-muted">
|
||||
Se abre el corte de caja de hoy sin perder el ticket. El cierre guiado (conteo por método)
|
||||
se hace en <Link to="/cortes" className="underline font-medium text-theme-heading">Cortes de caja</Link>.
|
||||
</p>
|
||||
<Input
|
||||
label="Efectivo inicial (fondo de caja)"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={cajaFondo}
|
||||
onChange={(e) => setCajaFondo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Modal de cobro */}
|
||||
<Modal
|
||||
isOpen={cobroOpen}
|
||||
|
||||
Reference in New Issue
Block a user