Files
SKEEN-Proyecto/frontend/src/pages/Pos.tsx

1503 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FC } 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, TrendingUp, LayoutGrid, List, Layers,
} from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import TicketPrint from '../components/TicketPrint';
import {
Card,
Button,
Input,
Select,
Modal,
EmptyState,
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
import type { PatientPackage } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useClinicSettings } from '../lib/clinic-settings';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
const fmtUsd = (n: number) =>
`$${(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD`;
const METODO_OPTIONS = [
{ value: 'cash', label: 'Efectivo' },
{ value: 'card', label: 'Tarjeta' },
{ value: 'transfer', label: 'Transferencia' },
{ value: 'stripe', label: 'Stripe' },
{ value: 'mercadopago', label: 'MercadoPago' },
{ value: 'saldo_favor', label: 'Saldo a favor' },
];
const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m;
interface TicketLine {
key: string;
name: string;
qty: number;
price: number;
service_id?: number;
item_id?: number;
paquete_id?: number;
sesiones?: number;
stock?: number;
unit?: string;
/** Tasa de impuesto (%) de la línea (0 con impuestos apagados) */
tax_rate?: number;
}
const Pos: FC = () => {
const { openPatient } = usePatientDrawer();
const { can } = useAuth();
const impuestos = useClinicSettings().impuestos;
const puedeImpuestos = can('ventas.impuestos');
const [searchParams, setSearchParams] = useSearchParams();
// Catálogo
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('');
const [categoria, setCategoria] = useState('');
const [soloFavoritos, setSoloFavoritos] = useState(false);
const [ordenServicios, setOrdenServicios] = useState<'manual' | 'best_sellers'>('manual');
// Vista del catálogo: cuadrícula (tarjetas) o lista compacta, para catálogos
// largos. Se recuerda entre sesiones como el resto de preferencias.
const [vista, setVista] = useState<'grid' | 'lista'>(() => {
try {
return localStorage.getItem('skeen_pos_vista') === 'lista' ? 'lista' : 'grid';
} catch {
return 'grid';
}
});
const cambiarVista = (v: 'grid' | 'lista') => {
setVista(v);
try {
localStorage.setItem('skeen_pos_vista', v);
} catch {
// localStorage no disponible: queda solo en memoria
}
};
// Orden de la lista: por nombre o por precio (click en el encabezado)
const [ordenLista, setOrdenLista] = useState<{ campo: 'nombre' | 'precio'; dir: 1 | -1 }>({ campo: 'nombre', dir: 1 });
const alternarOrdenLista = (campo: 'nombre' | 'precio') =>
setOrdenLista((prev) => (prev.campo === campo ? { campo, dir: prev.dir === 1 ? -1 : 1 } : { campo, dir: 1 }));
const [invItems, setInvItems] = useState<InventoryItem[]>([]);
// Ticket
const [lines, setLines] = useState<TicketLine[]>([]);
const [discount, setDiscount] = useState('');
const [paciente, setPaciente] = useState<Patient | null>(null);
const [pacienteAdeudo, setPacienteAdeudo] = useState(0);
const [pacienteQuery, setPacienteQuery] = useState('');
const [pacienteResults, setPacienteResults] = useState<Patient[]>([]);
const [altaRapida, setAltaRapida] = useState(false);
const [altaForm, setAltaForm] = useState({ name: '', phone: '' });
// Cobro
const [cobroOpen, setCobroOpen] = useState(false);
const [metodo, setMetodo] = useState('cash');
const [moneda, setMoneda] = useState<'MXN' | 'USD'>('MXN');
const [recibido, setRecibido] = useState('');
const [conPuntos, setConPuntos] = useState(false);
const [cobrando, setCobrando] = useState(false);
const [tcRate, setTcRate] = useState<number | null>(null);
// Doctor que atendió/recetó: se aplica como prescribed_by_id a todas las
// líneas al cobrar (alimenta "Mis ventas" y comisiones). Opcional.
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [doctorId, setDoctorId] = useState('');
// Resultado
const [resultado, setResultado] = useState<PosCheckoutResult | null>(null);
// 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);
// ---- Catálogo ----
// Orden del catálogo de servicios: manual (sequence definida en Servicios)
// o por más vendidos de los últimos 90 días.
const loadServices = useCallback(async () => {
try {
setCatalogLoading(true);
const res = await odooApi.getServices({
search: search || undefined,
category: categoria || undefined,
page_size: 200,
sort: ordenServicios,
});
if (res.status === 'success') setServices(res.services);
} catch (err) {
toast.error('Error al cargar servicios');
console.error(err);
} finally {
setCatalogLoading(false);
}
}, [search, categoria, ordenServicios]);
const loadItems = useCallback(async () => {
try {
setCatalogLoading(true);
const res = await odooApi.getInventarioItems(search || undefined);
if (res.status === 'success') setInvItems(res.items);
} catch (err) {
toast.error('Error al cargar artículos');
console.error(err);
} finally {
setCatalogLoading(false);
}
}, [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 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) => {
if (res.status === 'success') setCajaAbierta(res.cash_closings.some((c) => c.state === 'open'));
})
.catch(() => {});
}, []);
// Doctores activos para el selector "Doctor que atendió"
useEffect(() => {
odooApi.getDoctors()
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
.catch(() => {});
}, []);
// Pre-carga "Cobrar a la cita" desde Agenda:
// /pos?patient_id=X&service_id=Y&doctor_id=Z&appointment_id=N
// Se re-ejecuta cuando cambia patient_id (llegar a /pos ya montado desde el
// drawer no remonta el componente) y los params se consumen tras precargar.
const preloadPatientId = searchParams.get('patient_id');
useEffect(() => {
if (!preloadPatientId) return;
const sid = searchParams.get('service_id');
const did = searchParams.get('doctor_id');
(async () => {
try {
const res = await odooApi.getPatient(parseInt(preloadPatientId, 10));
if (res.status === 'success') selectPaciente(res.patient);
} catch {
toast.error('No se pudo cargar el paciente de la cita');
}
if (did) setDoctorId(did);
if (sid) {
try {
const sRes = await odooApi.getServices({ page_size: 200 });
const svc = (sRes.services || []).find((s) => s.id === parseInt(sid, 10));
if (svc) {
setLines((prev) => {
const key = `s-${svc.id}`;
if (prev.some((l) => l.key === key)) return prev;
return [...prev, { key, name: svc.name, qty: 1, price: svc.price, service_id: svc.id }];
});
} else {
toast.error('El servicio de la cita no está activo en el catálogo');
}
} catch {
toast.error('No se pudo cargar el servicio de la cita');
}
}
toast.success('Cita precargada en el ticket');
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
['patient_id', 'service_id', 'doctor_id', 'appointment_id'].forEach((k) => next.delete(k));
return next;
}, { replace: true });
})();
}, [preloadPatientId, searchParams, setSearchParams]);
// Tipo de cambio USD/MXN: al montar y refresco cada 5 min
useEffect(() => {
const fetchTc = () => {
odooApi.getExchangeRate()
.then((res) => { if (res.current) setTcRate(res.current.rate); })
.catch(() => {});
};
fetchTc();
const interval = setInterval(fetchTc, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
// Búsqueda server-side de paciente
useEffect(() => {
if (paciente || altaRapida) return;
if (pacienteQuery.trim().length < 2) {
setPacienteResults([]);
return;
}
const t = setTimeout(() => {
odooApi.getPatients({ search: pacienteQuery.trim(), page_size: 8 })
.then((res) => { if (res.status === 'success') setPacienteResults(res.patients); })
.catch(() => {});
}, 300);
return () => clearTimeout(t);
}, [pacienteQuery, paciente, altaRapida]);
const categorias = useMemo(() => {
const set = new Map<string, string>();
services.forEach((s) => { if (s.category) set.set(s.category, s.category); });
return [...set.keys()];
}, [services]);
const visibles = useMemo(
() => (soloFavoritos ? services.filter((s) => s.is_favorite) : services),
[services, soloFavoritos]
);
// Los artículos ya vienen filtrados del server-side (search)
const visiblesItems = invItems;
// Lista ordenable por nombre o precio (click en el encabezado)
const serviciosLista = useMemo(() => {
const arr = [...visibles];
arr.sort((a, b) => {
const v = ordenLista.campo === 'nombre'
? a.name.localeCompare(b.name, 'es')
: (a.price || 0) - (b.price || 0);
return v * ordenLista.dir;
});
return arr;
}, [visibles, ordenLista]);
const itemsLista = useMemo(() => {
const arr = [...visiblesItems];
arr.sort((a, b) => {
const v = ordenLista.campo === 'nombre'
? a.name.localeCompare(b.name, 'es')
: (a.price || 0) - (b.price || 0);
return v * ordenLista.dir;
});
return arr;
}, [visiblesItems, ordenLista]);
// 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,
tax_rate: tasaDePaquete(p),
}];
});
};
// ---- Ticket ----
// Tasa de impuesto que corresponde a un platillo del catálogo (0 si apagado).
const tasaDeServicio = (s: Service) =>
impuestos.activo ? (s.tax_use_default !== false ? impuestos.tasa_default : (s.tax_rate ?? 0)) : 0;
const tasaDeItem = (it: InventoryItem) =>
impuestos.activo ? (it.tax_use_default !== false ? impuestos.tasa_default : (it.tax_rate ?? 0)) : 0;
const tasaDePaquete = (p: Paquete) => {
if (!impuestos.activo) return 0;
const servicio = services.find((s) => s.id === p.service_id);
return servicio ? tasaDeServicio(servicio) : impuestos.tasa_default;
};
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));
return [...prev, { key, name: service.name, qty: 1, price: service.price, service_id: service.id, tax_rate: tasaDeServicio(service) }];
});
};
const addItemLine = (item: InventoryItem) => {
setLines((prev) => {
const key = `i-${item.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: item.name, qty: 1, price: item.price || 0, item_id: item.id, stock: item.qty, unit: item.unit, tax_rate: tasaDeItem(item) }];
});
};
const updateLine = (key: string, patch: Partial<TicketLine>) => {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
};
const removeLine = (key: string) => setLines((prev) => prev.filter((l) => l.key !== key));
const limpiarTicket = () => {
if (lines.length === 0) return;
if (window.confirm('¿Vaciar el ticket?')) setLines([]);
};
// Desglose del ticket. Con los impuestos apagados: bruto === base, impuesto 0
// y el total sale exactamente igual que antes.
const bruto = lines.reduce((a, l) => a + l.qty * l.price, 0);
const impuestoLinea = (l: TicketLine): number => {
if (!impuestos.activo) return 0;
const t = l.tax_rate ?? 0;
if (t <= 0) return 0;
const b = l.qty * l.price;
return impuestos.precios_incluyen_impuesto ? b - b / (1 + t / 100) : (b * t) / 100;
};
const impuesto = impuestos.activo ? lines.reduce((a, l) => a + impuestoLinea(l), 0) : 0;
const subtotal = impuestos.activo && impuestos.precios_incluyen_impuesto ? bruto - impuesto : bruto;
const discountNum = Math.min(parseFloat(discount) || 0, subtotal);
const total = Math.max(0, subtotal - discountNum + impuesto);
const numItems = lines.reduce((a, l) => a + l.qty, 0);
const puntosPaciente = paciente?.wallet_points ?? 0;
const puedePuntos = puntosPaciente >= total && total > 0;
// Equivalentes USD (si hay tipo de cambio configurado)
const toUsd = (mxn: number): number | null => (tcRate ? mxn / tcRate : null);
const recibidoMxn = recibido
? moneda === 'USD' && tcRate
? (parseFloat(recibido) || 0) * tcRate
: parseFloat(recibido) || 0
: 0;
const cambio = metodo === 'cash' && recibido ? Math.max(0, recibidoMxn - total) : 0;
const cambioUsd = moneda === 'USD' && tcRate ? cambio / tcRate : 0;
const crearPacienteRapido = async () => {
if (!altaForm.name.trim() || !altaForm.phone.trim()) {
toast.error('Nombre y teléfono son obligatorios');
return;
}
try {
const res = await odooApi.createPatient({ name: altaForm.name.trim(), phone: altaForm.phone.trim() });
if (res.status === 'success') {
setPaciente(res.patient);
setAltaRapida(false);
setAltaForm({ name: '', phone: '' });
toast.success('Paciente creado');
}
} catch (err) {
toast.error('Error al crear paciente');
console.error(err);
}
};
const abrirCobro = () => {
if (!paciente) {
toast.error('Selecciona un paciente');
return;
}
if (lines.length === 0) {
toast.error('El ticket está vacío');
return;
}
setMetodo('cash');
setMoneda('MXN');
setRecibido('');
setConPuntos(false);
setCobroOpen(true);
};
const cobrar = async () => {
if (!paciente) return;
try {
setCobrando(true);
const res = await odooApi.posCheckout({
partner_id: paciente.id,
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,
...(impuestos.activo ? { tax_rate: l.tax_rate ?? 0 } : {}),
// Asignación a nivel venta: el doctor seleccionado aplica a todas las líneas
...(doctorId ? { prescribed_by_id: parseInt(doctorId, 10) } : {}),
})),
discount: discountNum,
payment_method: metodo,
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
pay_with_points: conPuntos,
currency: moneda,
});
if (res.status === 'success') {
setResultado(res);
setCobroOpen(false);
toast.success(`Venta ${res.sale.name} cobrada`);
if (res.stock_warnings && res.stock_warnings.length > 0) {
res.stock_warnings.forEach((w) => toast.error(`Stock: ${w}`));
}
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cobrar');
console.error(err);
} finally {
setCobrando(false);
}
};
// Selector "Doctor que atendió": se comparte entre el ticket y el modal de cobro
const doctorBlock = (
<Select
label="Doctor que atendió"
options={[
{ value: '', label: 'Sin asignar' },
...doctors.map((d) => ({
value: String(d.id),
label: (d.staff_type ?? 'doctor') === 'cosmetologa' ? `${d.name} (Cosmetóloga)` : d.name,
})),
]}
value={doctorId}
onChange={(e) => setDoctorId(e.target.value)}
/>
);
// 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; }
const tasa = l.kind === 'service'
? (() => { const s = services.find((x) => x.id === l.service_id); return s ? tasaDeServicio(s) : impuestos.tasa_default; })()
: (() => { const it = invItems.find((x) => x.id === l.item_id); return it ? tasaDeItem(it) : impuestos.tasa_default; })();
nuevas.push({
key,
name: l.name,
qty: l.quantity,
price: l.price_current ?? l.price_last,
tax_rate: impuestos.activo ? tasa : 0,
...(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([]);
setDiscount('');
setPaciente(null);
setPacienteAdeudo(0);
setPacienteQuery('');
setDoctorId('');
setPaquetes([]);
};
// ---- Bloques del ticket (desktop sticky y modal móvil) ----
const pacienteBlock = paciente ? (
<div className="p-2.5 bg-theme-bg rounded-xl space-y-1.5">
<div className="flex items-center justify-between">
<div className="min-w-0">
<button
type="button"
onClick={() => openPatient(paciente.id)}
className="text-sm font-medium text-theme-heading truncate hover:underline hover:text-theme-accent"
title="Abrir ficha del paciente"
>
{paciente.name}
</button>
<p className="text-xs text-theme-muted flex items-center gap-1.5">
{paciente.phone}
{puntosPaciente > 0 && (
<span className="inline-flex items-center gap-0.5"><Wallet size={11} /> {puntosPaciente} pts</span>
)}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => { setPaciente(null); setPacienteAdeudo(0); setPacienteQuery(''); }}>Cambiar</Button>
</div>
{/* Alertas para caja antes de cobrar: VIP / Alergias / Adeudo */}
{(paciente.is_vip || paciente.allergies || pacienteAdeudo > 0) && (
<div className="flex flex-wrap gap-1.5">
{paciente.is_vip && (
<span className="inline-flex items-center gap-1 rounded-full border border-theme-accent bg-theme-accent-bg px-2 py-0.5 text-[11px] font-medium text-theme-heading">
<Star size={11} /> VIP
</span>
)}
{paciente.allergies && (
<span
className="inline-flex items-center gap-1 rounded-full border border-rose-300 bg-rose-50 px-2 py-0.5 text-[11px] font-medium text-rose-700"
title={`Alergias: ${paciente.allergies}`}
>
<AlertTriangle size={11} /> Alergias
</span>
)}
{pacienteAdeudo > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700">
Adeudo {fmtMoney(pacienteAdeudo)}
</span>
)}
</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">
<Input placeholder="Nombre *" value={altaForm.name} onChange={(e) => setAltaForm({ ...altaForm, name: e.target.value })} />
<Input placeholder="Teléfono *" value={altaForm.phone} onChange={(e) => setAltaForm({ ...altaForm, phone: e.target.value })} />
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setAltaRapida(false)}>Cancelar</Button>
<Button size="sm" onClick={crearPacienteRapido}>Crear paciente</Button>
</div>
</div>
) : (
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar paciente por nombre o teléfono..."
value={pacienteQuery}
onChange={(e) => setPacienteQuery(e.target.value)}
className="pl-10"
/>
{pacienteResults.length > 0 && (
<div className="absolute z-20 top-full left-0 right-0 mt-1 rounded-xl border border-theme-border bg-theme-surface shadow-card max-h-56 overflow-y-auto">
{pacienteResults.map((p) => (
<button
key={p.id}
type="button"
onClick={() => selectPaciente(p)}
className="w-full text-left px-3 py-2 text-sm hover:bg-theme-bg transition"
>
<span className="font-medium text-theme-heading">{p.name}</span>
<span className="text-theme-muted"> · {p.phone}</span>
</button>
))}
<button
type="button"
onClick={() => { setAltaRapida(true); setPacienteResults([]); }}
className="w-full text-left px-3 py-2 text-sm text-theme-heading hover:bg-theme-bg border-t border-theme-border flex items-center gap-1.5"
>
<Plus size={14} /> Alta rápida de paciente
</button>
</div>
)}
{pacienteQuery.trim().length >= 2 && pacienteResults.length === 0 && (
<button
type="button"
onClick={() => setAltaRapida(true)}
className="mt-1.5 text-xs text-theme-heading underline flex items-center gap-1"
>
<Plus size={12} /> Alta rápida de paciente
</button>
)}
</div>
);
const linesBlock = lines.length === 0 ? (
<p className="text-sm text-theme-muted text-center py-8">Toca un servicio para agregarlo al ticket.</p>
) : (
<div className="divide-y divide-theme-border">
{lines.map((l) => (
<div key={l.key} className="flex items-center gap-1.5 py-2">
<button
type="button"
onClick={() => removeLine(l.key)}
className="text-theme-muted hover:text-rose-600 shrink-0"
title="Quitar"
>
<X size={14} />
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-theme-heading truncate" title={l.name}>
{l.item_id && <Package size={12} className="inline mr-1 text-theme-muted" />}
{l.name}
{l.item_id && l.stock !== undefined && (
<span className="block text-[10px] font-normal text-theme-muted">{l.stock} {l.unit || 'pzas'} en existencia</span>
)}
</p>
{impuestos.activo && (
<span className="flex items-center gap-1.5 text-[11px] text-theme-muted">
<span>{impuestos.nombre}</span>
{puedeImpuestos ? (
<select
value={String(l.tax_rate ?? 0)}
onChange={(e) => updateLine(l.key, { tax_rate: parseFloat(e.target.value) || 0 })}
className="border border-theme-border-strong rounded-md px-1 py-0.5 text-[11px]"
title={`Tasa de ${impuestos.nombre} de la línea`}
>
{[...new Set([...(impuestos.tasa_opciones || []), l.tax_rate ?? 0])]
.sort((a, b) => a - b)
.map((o) => <option key={o} value={String(o)}>{o}%</option>)}
</select>
) : (
<span>{l.tax_rate ?? 0}%</span>
)}
{impuestoLinea(l) > 0 && (
<span>{fmtMoney(impuestoLinea(l))}</span>
)}
</span>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.key, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>
<span className="w-6 text-center text-sm font-medium text-theme-heading">{l.qty}</span>
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.key, { qty: l.qty + 1 })}><Plus size={12} /></Button>
</div>
<input
type="number"
min={0}
step={50}
value={l.price}
onChange={(e) => updateLine(l.key, { price: parseFloat(e.target.value) || 0 })}
className="w-[70px] shrink-0 border border-theme-border-strong rounded-lg px-1.5 py-1 text-xs text-right"
title="Precio unitario"
/>
<span className="w-[72px] shrink-0 text-sm font-semibold text-theme-heading text-right">{fmtMoney(l.qty * l.price)}</span>
</div>
))}
</div>
);
const totalsBlock = (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">Subtotal</span>
<span className="text-theme-heading">
{fmtMoney(subtotal)}
<span className="text-xs text-theme-muted ml-1.5" title={toUsd(subtotal) === null ? 'Configura el tipo de cambio en Configuración' : undefined}>
{toUsd(subtotal) !== null ? `≈ ${fmtUsd(toUsd(subtotal)!)}` : '—'}
</span>
</span>
</div>
{impuestos.activo && (
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">
{impuestos.nombre}{impuestos.precios_incluyen_impuesto ? ' (incluido)' : ''}
</span>
<span className="text-theme-heading">
{fmtMoney(impuesto)}
<span className="text-xs text-theme-muted ml-1.5">
{toUsd(impuesto) !== null ? `≈ ${fmtUsd(toUsd(impuesto)!)}` : '—'}
</span>
</span>
</div>
)}
<div className="flex items-center justify-between text-sm gap-2">
<span className="text-theme-muted">Descuento</span>
<span className="flex items-center gap-1.5">
{discountNum > 0 && toUsd(discountNum) !== null && (
<span className="text-xs text-theme-muted">≈ {fmtUsd(toUsd(discountNum)!)}</span>
)}
<input
type="number"
min={0}
step={50}
value={discount}
onChange={(e) => setDiscount(e.target.value)}
placeholder="0"
disabled={!can('ventas.descuentos')}
title={can('ventas.descuentos') ? undefined : 'Sin permiso para aplicar descuentos'}
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right disabled:opacity-50 disabled:cursor-not-allowed"
/>
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-theme-heading">Total</span>
<span className="text-right">
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
<span
className="block text-xs text-theme-muted"
title={toUsd(total) === null ? 'Configura el tipo de cambio en Configuración' : undefined}
>
{toUsd(total) !== null ? `≈ ${fmtUsd(toUsd(total)!)}` : '—'}
</span>
</span>
</div>
<div className="border border-theme-border rounded-xl p-2.5 bg-theme-bg">
{doctorBlock}
</div>
<Button
className="w-full mt-1 !py-3 text-base"
onClick={abrirCobro}
disabled={lines.length === 0 || !paciente || !can('ventas.vender')}
title={can('ventas.vender') ? 'Cobrar (F9)' : 'Sin permiso para vender'}
>
<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 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
</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>
)}
<div className="flex flex-col lg:flex-row gap-4 items-start">
{/* Catálogo */}
<Card className="flex-1 min-w-0 w-full">
<Card.Body>
<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)' : catMode === 'paquetes' ? 'Buscar paquete... (F2)' : 'Buscar artículo... (F2)'}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
{/* Toggle Servicios / Paquetes / Artículos */}
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3">
<button
type="button"
onClick={() => setCatMode('servicios')}
className={`px-4 py-1.5 text-sm font-medium transition flex items-center gap-1.5 ${
catMode === 'servicios' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
>
<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')}
className={`px-4 py-1.5 text-sm font-medium transition flex items-center gap-1.5 ${
catMode === 'articulos' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
>
<Package size={14} /> Artículos
</button>
</div>
{catMode === 'servicios' && (
<div className="flex flex-wrap gap-1.5 mb-3">
<button
type="button"
onClick={() => setSoloFavoritos((v) => !v)}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition flex items-center gap-1 ${
soloFavoritos ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
<Star size={12} fill={soloFavoritos ? 'currentColor' : 'none'} /> Favoritos
</button>
<button
type="button"
onClick={() => { setCategoria(''); setSoloFavoritos(false); }}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
categoria === '' && !soloFavoritos ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
Todos
</button>
{categorias.map((c) => (
<button
key={c}
type="button"
onClick={() => { setCategoria(c); setSoloFavoritos(false); }}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition capitalize ${
categoria === c ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{c}
</button>
))}
<span className="w-px self-stretch bg-theme-border mx-1" />
<button
type="button"
onClick={() => setOrdenServicios('manual')}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
ordenServicios === 'manual' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
title="Orden definido en el catálogo de Servicios"
>
Orden manual
</button>
<button
type="button"
onClick={() => setOrdenServicios('best_sellers')}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition flex items-center gap-1 ${
ordenServicios === 'best_sellers' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
title="Ordenado por ventas de los últimos 90 días"
>
<TrendingUp size={12} /> Más vendidos
</button>
</div>
)}
{catMode !== 'paquetes' && (
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3 ml-2">
<button
type="button"
onClick={() => cambiarVista('grid')}
className={`px-3 py-1.5 text-xs font-medium transition flex items-center gap-1 ${
vista === 'grid' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
title="Vista de cuadrícula"
>
<LayoutGrid size={12} /> Cuadrícula
</button>
<button
type="button"
onClick={() => cambiarVista('lista')}
className={`px-3 py-1.5 text-xs font-medium transition flex items-center gap-1 ${
vista === 'lista' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
title="Vista de lista (catálogos largos)"
>
<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">
<thead className="bg-theme-bg sticky top-0 z-10 text-theme-muted text-[11px] uppercase">
<tr>
<th
className="text-left px-3 py-2 cursor-pointer select-none"
onClick={() => alternarOrdenLista('nombre')}
title="Ordenar por nombre"
>
{catMode === 'servicios' ? 'Servicio' : 'Artículo'}
{ordenLista.campo === 'nombre' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')}
</th>
<th className="text-left px-3 py-2 hidden sm:table-cell">Categoría</th>
{catMode === 'servicios' && <th className="text-left px-3 py-2 hidden md:table-cell">Duración</th>}
<th
className="text-right px-3 py-2 cursor-pointer select-none"
onClick={() => alternarOrdenLista('precio')}
title="Ordenar por precio"
>
Precio
{ordenLista.campo === 'precio' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')}
</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{catMode === 'servicios' ? (
serviciosLista.length === 0 ? (
<tr><td colSpan={5} className="px-3 py-6 text-center text-theme-muted">Sin servicios con estos filtros.</td></tr>
) : serviciosLista.map((sv) => (
<tr key={sv.id} className="hover:bg-theme-bg">
<td className="px-3 py-2">
<p className="font-medium text-theme-heading">{sv.name}</p>
{sv.code && <p className="text-[11px] text-theme-muted">{sv.code}</p>}
</td>
<td className="px-3 py-2 text-theme-muted capitalize hidden sm:table-cell">{sv.category || '—'}</td>
<td className="px-3 py-2 text-theme-muted hidden md:table-cell">{sv.duration_min} min</td>
<td className="px-3 py-2 text-right font-semibold text-theme-heading">{fmtMoney(sv.price)}</td>
<td className="px-3 py-2 text-right">
<Button size="sm" onClick={() => addLine(sv)} title="Agregar al ticket">
<Plus size={13} className="mr-1" /> Agregar
</Button>
</td>
</tr>
))
) : (
itemsLista.length === 0 ? (
<tr><td colSpan={4} className="px-3 py-6 text-center text-theme-muted">Sin artículos con esta búsqueda.</td></tr>
) : itemsLista.map((it) => (
<tr key={it.id} className="hover:bg-theme-bg">
<td className="px-3 py-2">
<p className="font-medium text-theme-heading">{it.name}</p>
<p className={`text-[11px] ${it.qty <= 0 ? 'text-rose-600' : 'text-theme-muted'}`}>
{it.qty} {it.unit || 'pzas'} en existencia
</p>
</td>
<td className="px-3 py-2 text-theme-muted hidden sm:table-cell">{it.category || '—'}</td>
<td className="px-3 py-2 text-right font-semibold text-theme-heading">
{(it.price ?? 0) > 0 ? fmtMoney(it.price!) : <span className="text-xs font-normal text-theme-muted">sin precio</span>}
</td>
<td className="px-3 py-2 text-right">
<Button size="sm" onClick={() => addItemLine(it)} title="Agregar al ticket">
<Plus size={13} className="mr-1" /> Agregar
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
) : catMode === 'articulos' ? (
visiblesItems.length === 0 ? (
<EmptyState title="Sin artículos" subtitle="No hay artículos de inventario con esta búsqueda." icon={<Package size={28} />} />
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
{visiblesItems.map((i) => (
<button
key={i.id}
type="button"
onClick={() => addItemLine(i)}
className="text-left p-2.5 bg-theme-bg rounded-xl border border-theme-border hover:shadow-card hover:border-theme-border-strong transition"
>
<p className="text-sm font-medium text-theme-heading leading-tight line-clamp-2 min-h-[2.4rem]" title={i.name}>
{i.name}
</p>
<div className="flex items-center justify-between mt-1">
<span className={`text-xs ${i.qty <= 0 ? 'text-rose-600' : 'text-theme-muted'}`}>{i.qty} {i.unit || 'pzas'}</span>
<span className="text-sm font-bold text-theme-heading">{(i.price ?? 0) > 0 ? fmtMoney(i.price!) : <span className="text-xs font-normal text-theme-muted">sin precio</span>}</span>
</div>
</button>
))}
</div>
)
) : visibles.length === 0 ? (
<EmptyState title="Sin servicios" subtitle="No hay servicios con estos filtros." icon={<Store size={28} />} />
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
{visibles.map((s) => (
<button
key={s.id}
type="button"
onClick={() => addLine(s)}
className="text-left p-2.5 bg-theme-bg rounded-xl border border-theme-border hover:shadow-card hover:border-theme-border-strong transition"
>
<p className="text-sm font-medium text-theme-heading leading-tight line-clamp-2 min-h-[2.4rem]" title={s.name}>
{s.name}
</p>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-theme-muted">{s.duration_min} min</span>
<span className="text-sm font-bold text-theme-heading">{fmtMoney(s.price)}</span>
</div>
</button>
))}
</div>
)}
</Card.Body>
</Card>
{/* 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="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>
</div>
</Card>
</div>
{/* Botón flotante móvil */}
{lines.length > 0 && (
<button
type="button"
onClick={() => setTicketMovil(true)}
className="lg:hidden fixed bottom-4 right-4 z-30 px-5 py-3 rounded-full bg-theme-accent text-theme-inverse shadow-card font-semibold text-sm"
>
Ver ticket ({numItems}) {fmtMoney(total)}
</button>
)}
{/* Ticket móvil */}
<Modal isOpen={ticketMovil} onClose={() => setTicketMovil(false)} title="Ticket" maxWidth="md">
<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}
onClose={() => setCobroOpen(false)}
title={`Cobrar ${fmtMoney(total)}`}
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setCobroOpen(false)}>Cancelar</Button>
<Button onClick={cobrar} loading={cobrando}>Confirmar cobro</Button>
</>
}
>
<div className="space-y-4">
<div className="border border-theme-border rounded-xl p-3 bg-theme-bg">
{doctorBlock}
<p className="text-xs text-theme-muted mt-1">
Se asigna la venta completa a este doctor para sus comisiones.
</p>
</div>
<Select label="Método de pago" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
{metodo === 'cash' && !conPuntos && (
<>
<div>
<p className="block text-xs font-medium mb-1.5 text-theme-muted">Moneda</p>
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden">
<button
type="button"
onClick={() => setMoneda('MXN')}
className={`px-4 py-1.5 text-sm font-medium transition ${
moneda === 'MXN' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
>
MXN
</button>
<button
type="button"
onClick={() => tcRate && setMoneda('USD')}
disabled={!tcRate}
title={!tcRate ? 'Configura el tipo de cambio en Configuración' : undefined}
className={`px-4 py-1.5 text-sm font-medium transition ${
moneda === 'USD' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
} ${!tcRate ? 'opacity-40 cursor-not-allowed' : ''}`}
>
USD
</button>
</div>
{moneda === 'USD' && tcRate && (
<p className="text-xs text-theme-muted mt-1.5">Tipo de cambio: {tcRate.toFixed(2)} MXN por 1 USD</p>
)}
</div>
<Input
label={moneda === 'USD' ? 'Recibido (USD)' : 'Recibido'}
type="number"
min={0}
step={10}
value={recibido}
onChange={(e) => setRecibido(e.target.value)}
placeholder={moneda === 'USD' && tcRate ? (total / tcRate).toFixed(2) : String(total)}
/>
{moneda === 'USD' && recibido && tcRate && (
<p className="text-sm text-theme-muted">≈ {fmtMoney(recibidoMxn)} MXN</p>
)}
{recibido && (
<p className="text-sm">
<span className="text-theme-muted">Cambio: </span>
<span className={`font-semibold ${cambio > 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>
{fmtMoney(cambio)}
{moneda === 'USD' && cambio > 0 && (
<span className="text-theme-muted font-normal"> (≈ {fmtUsd(cambioUsd)})</span>
)}
</span>
</p>
)}
</>
)}
{puedePuntos && moneda === 'MXN' && (
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input
type="checkbox"
checked={conPuntos}
onChange={(e) => setConPuntos(e.target.checked)}
className="w-4 h-4 rounded border-theme-border-strong"
/>
<span className="text-sm text-theme-heading flex items-center gap-1.5">
<Wallet size={15} className="text-theme-muted" />
Pagar con saldo a favor ({puntosPaciente} pts = ${puntosPaciente})
</span>
</label>
)}
{conPuntos && (
<p className="text-xs text-theme-muted">
Se canjearán {Math.ceil(total)} pts (1 pt = $1). El paciente acumula puntos solo por lo pagado en dinero.
</p>
)}
</div>
</Modal>
{/* Ticket de éxito: se ofrece automáticamente tras el cobro (imprimible ~80mm) */}
<TicketPrint
isOpen={!!resultado}
onClose={nuevaVenta}
footerExtra={<Button variant="outline" onClick={nuevaVenta}>Nueva venta</Button>}
ticket={resultado && {
folio: resultado.sale.name,
fecha: `${resultado.sale.date} ${new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`,
paciente: resultado.sale.patient,
lines: resultado.sale.lines.map((l) => ({
name: l.service || l.item || l.description,
qty: l.quantity,
price: l.price_unit,
subtotal: l.subtotal,
})),
subtotal: resultado.sale.subtotal,
discount: resultado.sale.discount,
tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0,
tax_name: impuestos.nombre,
total: resultado.sale.total,
metodo: resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos',
puntos_usados: resultado.puntos_usados,
puntos_ganados: resultado.puntos_ganados,
wallet_points: resultado.wallet_points,
cambio: resultado.cambio,
recibido: resultado.recibido,
cambio_usd: resultado.cambio_usd,
recibido_usd: resultado.recibido_usd,
rate_used: resultado.rate_used,
cajero: resultado.sale.user_login,
doctor: resultado.sale.doctor,
telefono_paciente: resultado.sale.patient_phone,
notas: resultado.sale.notes,
}}
/>
</Layout>
);
};
export default Pos;