'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import DiscountPanel from '@/components/DiscountPanel'; import FreeItemModal from '@/components/FreeItemModal'; import PaymentModal from '@/components/PaymentModal'; import ReceiptModal from '@/components/ReceiptModal'; import ReturnModal from '@/components/ReturnModal'; import { posApi } from '@/lib/api-client'; import { generateIdempotencyKey } from '@/lib/idempotency'; import { formatPrice } from '@/lib/money'; import type { PaymentAllocation, PaymentMethod, PosPendingSale, PosReceipt, PosSaleResponse, RecoveredOrderItem, } from '@/types/checkout'; interface CartItem { lineId: string; kind: 'stock' | 'free'; variantId: string | null; productId: string | null; sku: string; name: string; ean: string | null; unitPriceCents: number; discountCents: number; taxCents: number; quantity: number; stock: number | null; } interface SearchResult { variantId: string; productId: string; name: string; sku: string; ean: string | null; stock: number; priceCents: number; categoryId?: string | null; } interface TouchCategory { id: string; parentId: string | null; name: string; emoji: string | null; bgColor: string | null; textColor: string | null; } interface TouchCatalog { enabled: boolean; categories: TouchCategory[]; products: SearchResult[]; quickProducts: Array; } interface Config { session: { id: string; storeId: string; status: string } | null; terminal: { id: string; name: string; settings?: { lineDiscountsEnabled?: boolean; selfpayMode?: boolean } & Record; }; store: { id: string; name: string }; paymentMethods: PaymentMethod[]; } interface Customer { id: string; email: string; displayName?: string | null; phone?: string | null; } export default function RegisterPage() { const [config, setConfig] = useState(null); const [configError, setConfigError] = useState(''); const [needsBinding, setNeedsBinding] = useState(false); const [bindingCode, setBindingCode] = useState(''); const [binding, setBinding] = useState(false); const [openingCash, setOpeningCash] = useState('0'); const [openingSession, setOpeningSession] = useState(false); const [sessionError, setSessionError] = useState(''); const [cart, setCart] = useState([]); const [search, setSearch] = useState(''); const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); const [searchError, setSearchError] = useState(''); const [touchCatalog, setTouchCatalog] = useState(null); const [categoryPath, setCategoryPath] = useState([]); const [selectedItem, setSelectedItem] = useState(null); const [showDiscountPanel, setShowDiscountPanel] = useState(false); const [showFreeItem, setShowFreeItem] = useState(false); const [paymentMethod, setPaymentMethod] = useState(null); const [payments, setPayments] = useState([]); const [receipt, setReceipt] = useState(null); const [customer, setCustomer] = useState(null); const [showCustomerSearch, setShowCustomerSearch] = useState(false); const [customerQuery, setCustomerQuery] = useState(''); const [customerResults, setCustomerResults] = useState([]); const [customerMode, setCustomerMode] = useState<'search' | 'create'>('search'); const [newCustomer, setNewCustomer] = useState({ email: '', displayName: '', phone: '' }); const [customerError, setCustomerError] = useState(''); const [creatingCustomer, setCreatingCustomer] = useState(false); const [processing, setProcessing] = useState(false); const [error, setError] = useState(''); const [pendingSales, setPendingSales] = useState([]); const [todaySales, setTodaySales] = useState([]); const [loadingPending, setLoadingPending] = useState(false); const [salesTab, setSalesTab] = useState<'pending' | 'day'>('day'); const [showSidebar, setShowSidebar] = useState(true); const [printingSaleId, setPrintingSaleId] = useState(null); const [restPaymentFor, setRestPaymentFor] = useState(null); const [processingRest, setProcessingRest] = useState(false); const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>( null, ); const [showClearConfirm, setShowClearConfirm] = useState(false); // POS-FIX-8: delete confirmation for pending sales const [deleteConfirmFor, setDeleteConfirmFor] = useState(null); const [recoveringSaleId, setRecoveringSaleId] = useState(null); // POS-FIX-9: merge dialog when recovering with cart items const [mergePendingSale, setMergePendingSale] = useState(null); // POS-FIX-11: park with name dialog const [pendingParkName, setPendingParkName] = useState(null); // POS-FIX-3/POS-FIX-5: close session const [showCloseSession, setShowCloseSession] = useState(false); const [closingActualCash, setClosingActualCash] = useState(''); const [closingPin, setClosingPin] = useState(''); const [closingPinError, setClosingPinError] = useState(''); const [closingPinStep, setClosingPinStep] = useState(false); // true = PIN entered, show cash dialog const [closing, setClosing] = useState(false); const [closeError, setCloseError] = useState(''); // POS-FIX-4: toast notification when product added to cart const [addedToast, setAddedToast] = useState(null); let toastTimer: ReturnType | undefined; const showAddedToast = (name: string) => { clearTimeout(toastTimer); setAddedToast(name); toastTimer = setTimeout(() => setAddedToast(null), 2000); }; // Clock state for TPV header const [currentTime, setCurrentTime] = useState(new Date()); useEffect(() => { const timer = setInterval(() => setCurrentTime(new Date()), 1000); return () => clearInterval(timer); }, []); // POS-FIX-5: verify PIN then close session const handleVerifyPin = async () => { if (!config?.terminal) return; if (!closingPin || closingPin.length < 4) { setClosingPinError('PIN requerido'); return; } setClosingPinError(''); setClosingPinStep(true); // proceed to cash amount dialog }; const handleCloseSession = async () => { if (!config?.session) return; const actual = parseInt(closingActualCash, 10); if (isNaN(actual) || actual < 0) { setCloseError('Cantidad inválida'); return; } setClosing(true); setCloseError(''); try { await posApi.closeSession(config.session.id, actual, closingPin); window.location.reload(); } catch (err) { setCloseError(err instanceof Error ? err.message : 'Error al cerrar'); setClosing(false); } }; const loadConfig = useCallback(async () => { setConfigError(''); try { setConfig(await posApi.config()); setNeedsBinding(false); } catch (err: unknown) { const apiError = err as { code?: string; message?: string }; setConfig(null); setNeedsBinding(apiError.code === 'MISSING_TERMINAL_ID'); setConfigError(apiError.message ?? 'No se pudo cargar la configuración del TPV'); } }, []); useEffect(() => { void loadConfig(); }, [loadConfig]); // FEAT-200: pending sales by terminalId enables cross-day resumption const loadPendingSales = useCallback(async () => { if (!config?.terminal || config.session?.status !== 'OPEN') { setPendingSales([]); return; } setLoadingPending(true); try { const data = await posApi.listSales<{ items: PosPendingSale[] }>({ state: 'PENDING', terminalId: config.terminal.id, }); setPendingSales(data.items ?? []); } catch { setPendingSales([]); } finally { setLoadingPending(false); } }, [config?.terminal?.id, config?.session?.status]); // Load today's completed sales for the sidebar const loadTodaySales = useCallback(async () => { if (!config?.terminal) { setTodaySales([]); return; } try { const data = await posApi.listSales<{ items: PosPendingSale[] }>({ terminalId: config.terminal.id, }); // Filter to today's date (completed or any state) const today = new Date(); today.setHours(0, 0, 0, 0); const todayItems = (data.items ?? []).filter((sale) => { const saleDate = new Date(sale.createdAt); saleDate.setHours(0, 0, 0, 0); return saleDate.getTime() === today.getTime(); }); setTodaySales(todayItems); } catch { setTodaySales([]); } }, [config?.terminal?.id]); // POS-FIX-9: removed 10s polling — only manual refresh via button // POS-FIX-6: re-run when config loads (terminalId changes from undefined to real id) useEffect(() => { if (!config?.terminal?.id || config.session?.status !== 'OPEN') return; void loadPendingSales(); void loadTodaySales(); }, [config?.terminal?.id, config?.session?.status, loadPendingSales, loadTodaySales]); useEffect(() => { if (!config?.session || config.session.status !== 'OPEN') return; void posApi .touchCatalog() .then((catalog) => { setTouchCatalog(catalog); setCategoryPath([]); }) .catch(() => setTouchCatalog(null)); }, [config?.session?.id, config?.session?.status]); const bindTerminal = async (event: React.FormEvent) => { event.preventDefault(); setBinding(true); setConfigError(''); try { await posApi.bind(bindingCode.trim().toUpperCase()); setBindingCode(''); await loadConfig(); } catch (err) { setConfigError(err instanceof Error ? err.message : 'No se pudo vincular el terminal'); } finally { setBinding(false); } }; const openCashSession = async (event: React.FormEvent) => { event.preventDefault(); const amount = Number(openingCash.replace(',', '.')); if (!Number.isFinite(amount) || amount < 0) { setSessionError('Introduce un importe válido'); return; } setOpeningSession(true); setSessionError(''); try { await posApi.openSession(Math.round(amount * 100)); await loadConfig(); } catch (err) { setSessionError(err instanceof Error ? err.message : 'No se pudo abrir la caja'); } finally { setOpeningSession(false); } }; const doSearch = useCallback( async (query: string) => { if (query.trim().length < 2) { setSearchResults([]); setSearchError(''); return; } setSearching(true); setSearchError(''); try { const normalized = query.trim(); if (/^\d{8,14}$/.test(normalized)) { try { setSearchResults([(await posApi.productByEan(normalized)) as SearchResult]); return; } catch (err) { if ((err as { status?: number }).status !== 404) throw err; } } const response = (await posApi.searchProducts(normalized, config?.session?.storeId)) as { items: SearchResult[]; }; setSearchResults(response.items ?? []); } catch (err) { setSearchResults([]); setSearchError(err instanceof Error ? err.message : 'No se pudo buscar productos'); } finally { setSearching(false); } }, [config?.session?.storeId], ); useEffect(() => { const timer = window.setTimeout(() => { void doSearch(search); }, 300); return () => window.clearTimeout(timer); }, [search, doSearch]); const resetAllocations = () => { setPayments([]); setPaymentMethod(null); setError(''); }; const addToCart = (product: SearchResult) => { setCart((current) => { const existing = current.find( (item) => item.kind === 'stock' && item.variantId === product.variantId, ); if (existing) { return current.map((item) => item.lineId === existing.lineId ? { ...item, quantity: item.quantity + 1 } : item, ); } return [ ...current, { lineId: product.variantId, kind: 'stock', variantId: product.variantId, productId: product.productId, sku: product.sku, name: product.name, ean: product.ean, unitPriceCents: product.priceCents, discountCents: 0, taxCents: 0, quantity: 1, stock: product.stock, }, ]; }); showAddedToast(product.name); resetAllocations(); setSearch(''); setSearchResults([]); }; const addSearchResultWithEnter = async () => { const normalized = search.trim(); if (!normalized) return; setSearchError(''); try { if (/^\d{8,14}$/.test(normalized)) { addToCart((await posApi.productByEan(normalized)) as SearchResult); } else if (searchResults.length === 1 && searchResults[0]) { addToCart(searchResults[0]); } } catch (err) { setSearchError( (err as { status?: number }).status === 404 ? `No existe ningún producto con EAN ${normalized}` : err instanceof Error ? err.message : 'No se pudo buscar el producto', ); } }; const addFreeItem = ({ name, unitPriceCents }: { name: string; unitPriceCents: number }) => { setCart((current) => [ ...current, { lineId: `free-${generateIdempotencyKey()}`, kind: 'free', variantId: null, productId: null, sku: 'LIBRE', name, ean: null, unitPriceCents, discountCents: 0, taxCents: 0, quantity: 1, stock: null, }, ]); resetAllocations(); setShowFreeItem(false); }; const removeFromCart = (lineId: string) => { setCart((current) => current.filter((item) => item.lineId !== lineId)); resetAllocations(); }; const updateQuantity = (lineId: string, quantity: number) => { if (quantity <= 0) { removeFromCart(lineId); return; } setCart((current) => current.map((item) => (item.lineId === lineId ? { ...item, quantity } : item)), ); resetAllocations(); }; const totals = useMemo(() => { const subtotal = cart.reduce((sum, item) => sum + item.unitPriceCents * item.quantity, 0); const discount = cart.reduce((sum, item) => sum + item.discountCents * item.quantity, 0); const tax = cart.reduce((sum, item) => sum + item.taxCents * item.quantity, 0); return { subtotal, discount, tax, total: subtotal - discount + tax }; }, [cart]); const paidCents = payments.reduce((sum, payment) => sum + payment.amountCents, 0); const remainingCents = Math.max(0, totals.total - paidCents); const isSelfpayMode = config?.terminal.settings?.selfpayMode === true; const lineDiscountsEnabled = !isSelfpayMode && config?.terminal.settings?.lineDiscountsEnabled !== false; const applyDiscount = (lineId: string, discountCents: number) => { setCart((current) => current.map((item) => (item.lineId === lineId ? { ...item, discountCents } : item)), ); resetAllocations(); }; const addPayment = (allocation: PaymentAllocation) => { setPayments((current) => [...current, allocation]); setPaymentMethod(null); setError(''); }; const confirmSale = async () => { if (!config?.session) { setError('No hay sesión abierta'); return; } if (cart.length === 0) { setError('Carrito vacío'); return; } if (payments.length === 0 || paidCents <= 0) { setError('Asigna al menos un pago antes de confirmar'); return; } if (paidCents > totals.total) { setError('Los pagos asignados superan el total'); return; } setProcessing(true); setError(''); try { // TPV-FIXES: items without variantId (recovered sales) must be sent as free items const saleItems = cart.map((item) => { if (item.kind === 'free' || !item.variantId) { return { kind: 'free' as const, name: item.name, unitPriceCents: item.unitPriceCents, quantity: item.quantity, }; } return { kind: 'stock' as const, variantId: item.variantId, quantity: item.quantity, discountCents: item.discountCents, }; }); const result = await posApi.createSale({ idempotencyKey: generateIdempotencyKey(), cashSessionId: config.session.id, terminalId: config.terminal.id, items: saleItems, payments: payments.map((payment) => ({ methodCode: payment.methodCode, amountCents: payment.amountCents, ...(payment.kind === 'cash' ? { tenderedCents: payment.tenderedCents } : {}), })), ...(customer ? { customerId: customer.id } : {}), }); setReceipt(result.receipt); setRestPaymentFor(null); void loadPendingSales(); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo confirmar la venta'); } finally { setProcessing(false); } }; const parkSale = async () => { if (!config?.session) { setError('No hay sesión abierta'); return; } if (cart.length === 0) { setError('Carrito vacío'); return; } // POS-FIX-11: if no customer, ask for a name if (!customer) { setPendingParkName(''); return; } await doParkSale(); }; // POS-FIX-11: actual parking with optional name const doParkSale = async (name?: string) => { if (!config?.session) { setError('No hay sesión abierta'); return; } setProcessing(true); setError(''); try { // TPV-FIXES: items without variantId (recovered sales) must be sent as free items const saleItems = cart.map((item) => { if (item.kind === 'free' || !item.variantId) { return { kind: 'free' as const, name: item.name, unitPriceCents: item.unitPriceCents, quantity: item.quantity, }; } return { kind: 'stock' as const, variantId: item.variantId, quantity: item.quantity, discountCents: item.discountCents, }; }); const result = await posApi.createSale({ idempotencyKey: generateIdempotencyKey(), cashSessionId: config.session.id, terminalId: config.terminal.id, items: saleItems, payments: [], ...(customer ? { customerId: customer.id } : {}), ...(name ? { posLabel: name } : {}), }); setRestPaymentFor(null); setPendingParkName(null); void loadPendingSales(); // Refresh the pending panel so the new parked sale appears setLoadingPending(true); try { const data = (await posApi.listSales({ state: 'PENDING', terminalId: config.terminal.id })) as { items: PosPendingSale[]; }; setPendingSales(data.items ?? []); } catch { // Non-fatal: the sale was created, panel refresh is best-effort } finally { setLoadingPending(false); } resetCashier(); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo aparcar la venta'); } finally { setProcessing(false); } }; const openRestPayment = (sale: PosPendingSale) => { setRestPaymentFor(sale); setError(''); }; const submitRestPayment = async (allocation: PaymentAllocation) => { if (!config?.session || !restPaymentFor) return; setProcessingRest(true); setError(''); try { const result = await posApi.payRest(restPaymentFor.id, { idempotencyKey: generateIdempotencyKey(), cashSessionId: config.session.id, terminalId: config.terminal.id, payments: [ { methodCode: allocation.methodCode, amountCents: allocation.amountCents, ...(allocation.kind === 'cash' ? { tenderedCents: allocation.tenderedCents } : {}), }, ], }); setRestPaymentFor(null); if (result.state === 'COMPLETED' && result.outstandingCents === 0) { setReceipt(result.receipt); } void loadPendingSales(); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo aplicar el cobro'); } finally { setProcessingRest(false); } }; // POS-FIX-12: reprint ticket — load receipt modal for printing const reprintSale = async (sale: PosPendingSale) => { setPrintingSaleId(sale.id); setError(''); try { const result = await posApi.getReceipt<{ receipt: PosReceipt }>(sale.id); // Show the receipt modal for printing setReceipt(result.receipt); // Also trigger print immediately after modal renders setTimeout(() => { const printBtn = document.querySelector('button[class*="bg-\[\#2D6A4F\]"]') as HTMLButtonElement | null; if (printBtn) printBtn.click(); }, 100); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo reimprimir el ticket'); } finally { setPrintingSaleId(null); } }; // POS-FIX-8: recover a parked sale into the current cart const recoverSale = async (sale: PosPendingSale) => { // POS-FIX-9: if cart has items, show merge dialog instead of auto-merge if (cart.length > 0) { // Don't set recoveringSaleId here — buttons in the dialog would be disabled setMergePendingSale(sale); setError(''); return; } await doRecoverSale(sale); }; // POS-FIX-9: actual recovery after merge dialog decision const doRecoverSale = async (sale: PosPendingSale) => { setRecoveringSaleId(sale.id); setError(''); try { const data = (await posApi.listOrderItems<{ items: RecoveredOrderItem[] }>(sale.id)); const items: CartItem[] = data.items .filter((item) => !item.freeItem) .map((item) => ({ lineId: `${item.id}-recovered-${Date.now()}`, kind: 'stock' as const, variantId: null, productId: null, sku: item.sku, name: item.name, ean: null, unitPriceCents: item.unitPriceCents, discountCents: item.discountCents, taxCents: 0, quantity: item.quantity - item.returnedQuantity, stock: null, })); setCart(items); setMergePendingSale(null); void loadPendingSales(); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo recuperar la venta'); } finally { setRecoveringSaleId(null); } }; // POS-FIX-9: park current cart and recover selected sale const parkAndRecover = async () => { if (!config?.session || !mergePendingSale) return; setRecoveringSaleId(mergePendingSale.id); setError(''); try { // TPV-FIXES: items without variantId (recovered sales) must be sent as free items const parkItems = cart.map((item) => { if (item.kind === 'free' || !item.variantId) { // Free item or recovered item without variantId return { kind: 'free' as const, name: item.name, unitPriceCents: item.unitPriceCents, quantity: item.quantity, }; } return { kind: 'stock' as const, variantId: item.variantId, quantity: item.quantity, discountCents: item.discountCents, }; }); // Park current cart first (with customer if available) await posApi.createSale({ idempotencyKey: generateIdempotencyKey(), cashSessionId: config.session.id, terminalId: config.terminal.id, items: parkItems, payments: [], ...(customer ? { customerId: customer.id } : {}), }); // Then recover the selected sale await doRecoverSale(mergePendingSale); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo aparcar y recuperar'); } }; // POS-FIX-9: add recovered items to current cart (merge) const mergeRecoveredItems = async () => { if (!mergePendingSale) return; setRecoveringSaleId(mergePendingSale.id); setError(''); try { const data = (await posApi.listOrderItems<{ items: RecoveredOrderItem[] }>(mergePendingSale.id)); const items: CartItem[] = data.items .filter((item) => !item.freeItem) .map((item) => ({ lineId: `${item.id}-recovered-${Date.now()}`, kind: 'stock' as const, variantId: null, productId: null, sku: item.sku, name: item.name, ean: null, unitPriceCents: item.unitPriceCents, discountCents: item.discountCents, taxCents: 0, quantity: item.quantity - item.returnedQuantity, stock: null, })); setCart((prev) => [...prev, ...items]); setMergePendingSale(null); void loadPendingSales(); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo añadir artículos'); } finally { setRecoveringSaleId(null); } }; // POS-FIX-8: delete a parked sale const deleteSale = async (sale: PosPendingSale) => { setError(''); try { await posApi.deleteSale(sale.id); setDeleteConfirmFor(null); void loadPendingSales(); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo eliminar la venta'); } }; const resetCashier = () => { setReceipt(null); setCart([]); setPayments([]); setCustomer(null); setError(''); setSearch(''); setShowClearConfirm(false); }; const searchCustomers = async (query: string) => { setCustomerQuery(query); if (query.trim().length < 2) { setCustomerResults([]); return; } setCustomerError(''); try { const data = (await posApi.searchCustomers(query)) as { items: Customer[] }; setCustomerResults(data.items ?? []); } catch (err) { setCustomerResults([]); setCustomerError(err instanceof Error ? err.message : 'No se pudieron buscar clientes'); } }; const createCustomer = async (event: React.FormEvent) => { event.preventDefault(); setCreatingCustomer(true); setCustomerError(''); try { const created = await posApi.createCustomer({ email: newCustomer.email.trim(), ...(newCustomer.displayName.trim() ? { displayName: newCustomer.displayName.trim() } : {}), ...(newCustomer.phone.trim() ? { phone: newCustomer.phone.trim() } : {}), }); setCustomer(created); setShowCustomerSearch(false); setCustomerMode('search'); setNewCustomer({ email: '', displayName: '', phone: '' }); } catch (err) { setCustomerError(err instanceof Error ? err.message : 'No se pudo crear el cliente'); } finally { setCreatingCustomer(false); } }; const currentCategory = categoryPath.at(-1) ?? null; const visibleCategories = (touchCatalog?.categories ?? []).filter( (category) => category.parentId === (currentCategory?.id ?? null), ); const visibleTouchProducts = currentCategory ? (touchCatalog?.products ?? []).filter((product) => product.categoryId === currentCategory.id) : []; if (!config && needsBinding) { return (

Vincular terminal

Introduce el código generado en administración.

setBindingCode(event.target.value.toUpperCase())} minLength={8} maxLength={8} autoComplete="off" className="w-full rounded-xl border px-4 py-3 text-center font-mono text-xl tracking-widest" placeholder="AB12CD34" required autoFocus /> {configError &&

{configError}

}
); } if (!config) { return (

{configError || 'Cargando TPV…'}

{configError && ( )}
); } if (config.session?.status !== 'OPEN') { return (

{config.store.name} · {config.terminal.name}

Abrir sesión de caja

Indica el efectivo disponible al comenzar el turno.

{sessionError &&

{sessionError}

}
); } return (
{!isSelfpayMode && showSidebar && ( )} {/* POS-FIX-4: product added toast */} {addedToast && (
{addedToast}
)}
{/* Panel toggle button - LEFT */} {!isSelfpayMode && ( )} {/* Store name */} {config.store.name} {/* Status badge */} Caja abierta {/* Clock */} {currentTime.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} · {currentTime.toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })} {!isSelfpayMode && ( )}
setSearch(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void addSearchResultWithEnter(); } }} placeholder="Buscar producto o escanear EAN…" className="min-w-0 flex-1 rounded-xl border-2 border-gray-200 px-4 py-3 text-lg outline-none focus:border-[#2D6A4F]" autoFocus />
{!search.trim() && touchCatalog?.enabled && (
{categoryPath.map((category, index) => ( ›{' '} ))}
{visibleCategories.map((category) => ( ))} {visibleTouchProducts.map((product) => ( ))}
)} {searchResults.length > 0 && (
{searchResults.map((product) => ( ))}
)} {searching &&

Buscando…

} {searchError &&

{searchError}

} {!search.trim() && touchCatalog?.enabled && (
{Array.from({ length: 8 }, (_, slot) => touchCatalog.quickProducts[slot] ?? null).map( (product, slot) => product ? ( ) : (
Rápido {slot + 1}
), )}
)}
{!isSelfpayMode && showDiscountPanel && selectedItem && (
{ applyDiscount(selectedItem.lineId, discountCents); setShowDiscountPanel(false); setSelectedItem(null); }} onClose={() => { setShowDiscountPanel(false); setSelectedItem(null); }} />
)} {showFreeItem && setShowFreeItem(false)} />} {paymentMethod && ( setPaymentMethod(null)} /> )} {restPaymentFor && (

Pendiente {formatPrice(restPaymentFor.outstandingCents)}

Cobrar resto

Venta {restPaymentFor.receiptNumber ?? restPaymentFor.id.slice(0, 8)}

{config.paymentMethods.map((method) => ( ))}
{error &&

{error}

}
)} {paymentMethod && restPaymentFor && ( { void submitRestPayment(allocation); }} onClose={() => setPaymentMethod(null)} /> )} {/* POS-FIX-7: confirmar antes de vaciar */} {showClearConfirm && (

¿Vaciar ticket?

Se borrarán {cart.length} {cart.length === 1 ? 'artículo' : 'artículos'} del ticket actual. Esta acción no se puede deshacer.

)} {/* POS-FIX-11: name dialog when parking without customer */} {pendingParkName !== null && (

Nombre para identificar

¿A nombre de quién es este ticket pendiente?

setPendingParkName(e.target.value)} placeholder="Ej: María García" maxLength={100} className="mb-4 w-full rounded-xl border border-gray-300 px-3 py-2 text-sm outline-none focus:border-[#2D6A4F]" autoFocus onKeyDown={(e) => { if (e.key === 'Enter' && pendingParkName.trim()) { void doParkSale(pendingParkName.trim()); } }} />
)} {/* POS-FIX-8: delete confirmation for pending sale */} {deleteConfirmFor && (

¿Eliminar venta?

Ticket {deleteConfirmFor.receiptNumber ?? deleteConfirmFor.id.slice(0, 8)}

Total {formatPrice(deleteConfirmFor.totalCents)} · pendiente {formatPrice(deleteConfirmFor.outstandingCents)}

⚠️ Se liberará el stock reservado y no se podrá deshacer.

{error &&

{error}

}
)} {/* POS-FIX-9: merge dialog when recovering a sale with items in cart */} {mergePendingSale && (

Ticket con artículos

Hay {cart.length} {cart.length === 1 ? 'artículo' : 'artículos'} en el ticket actual.

¿Qué deseas hacer con la venta {mergePendingSale.receiptNumber ?? mergePendingSale.id.slice(0, 8)}?

{error &&

{error}

}
)} {/* POS-FIX-3/POS-FIX-5: close session — 2-step: PIN then cash */} {showCloseSession && ( (config?.terminal?.settings?.closeSessionRequiresPin && !closingPinStep) ? ( // STEP 1: PIN

Cerrar caja

Introduce tu PIN de cajero para autorizar el cierre.

{ setClosingPin(e.target.value); setClosingPinError(''); }} placeholder="PIN" className="mb-1 w-full rounded-xl border border-gray-300 px-3 py-2 text-center text-2xl tracking-widest outline-none focus:border-[#2D6A4F]" autoFocus /> {closingPinError &&

{closingPinError}

}
) : ( // STEP 2: cash amount

Cerrar caja

Indica el efectivo real en caja para calcular la diferencia.

setClosingActualCash(e.target.value)} className="mb-1 w-full rounded-xl border border-gray-300 px-3 py-2 text-lg outline-none focus:border-[#2D6A4F]" autoFocus /> {closeError &&

{closeError}

}
) )} {receipt && ( { setReturnOrder({ orderId, receipt: latest }); } : undefined } /> )} {returnOrder && config?.session && config?.terminal && ( setReturnOrder(null)} onReturned={(latest) => { setReturnOrder(null); setReceipt(latest); }} /> )}
); }