Files
mercadodevida/project/apps/pos/src/app/(terminal)/page.tsx

2051 lines
80 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

'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<SearchResult | null>;
}
interface Config {
session: { id: string; storeId: string; status: string } | null;
terminal: {
id: string;
name: string;
settings?: { lineDiscountsEnabled?: boolean; selfpayMode?: boolean } & Record<string, unknown>;
};
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<Config | null>(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<CartItem[]>([]);
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState('');
const [touchCatalog, setTouchCatalog] = useState<TouchCatalog | null>(null);
const [categoryPath, setCategoryPath] = useState<TouchCategory[]>([]);
const [selectedItem, setSelectedItem] = useState<CartItem | null>(null);
const [showDiscountPanel, setShowDiscountPanel] = useState(false);
const [showFreeItem, setShowFreeItem] = useState(false);
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod | null>(null);
const [payments, setPayments] = useState<PaymentAllocation[]>([]);
const [receipt, setReceipt] = useState<PosReceipt | null>(null);
const [customer, setCustomer] = useState<Customer | null>(null);
const [showCustomerSearch, setShowCustomerSearch] = useState(false);
const [customerQuery, setCustomerQuery] = useState('');
const [customerResults, setCustomerResults] = useState<Customer[]>([]);
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<PosPendingSale[]>([]);
const [todaySales, setTodaySales] = useState<PosPendingSale[]>([]);
const [loadingPending, setLoadingPending] = useState(false);
const [salesTab, setSalesTab] = useState<'pending' | 'day'>('day');
const [showSidebar, setShowSidebar] = useState(true);
const [printingSaleId, setPrintingSaleId] = useState<string | null>(null);
const [restPaymentFor, setRestPaymentFor] = useState<PosPendingSale | null>(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<PosPendingSale | null>(null);
const [recoveringSaleId, setRecoveringSaleId] = useState<string | null>(null);
// POS-FIX-9: merge dialog when recovering with cart items
const [mergePendingSale, setMergePendingSale] = useState<PosPendingSale | null>(null);
// POS-FIX-11: park with name dialog
const [pendingParkName, setPendingParkName] = useState<string | null>(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<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | 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<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<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<PosSaleResponse>({
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<PosSaleResponse>({
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<PosSaleResponse>(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<PosSaleResponse>({
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<Customer>({
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 (
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-6">
<form
onSubmit={bindTerminal}
className="w-full max-w-sm space-y-4 rounded-2xl bg-white p-8 shadow-lg"
>
<div>
<h1 className="text-2xl font-bold">Vincular terminal</h1>
<p className="mt-1 text-sm text-gray-500">
Introduce el código generado en administración.
</p>
</div>
<input
value={bindingCode}
onChange={(event) => 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 && <p className="text-sm text-red-600">{configError}</p>}
<button
disabled={binding || bindingCode.trim().length !== 8}
className="min-h-12 w-full rounded-xl bg-[#2D6A4F] font-semibold text-white disabled:opacity-50"
>
{binding ? 'Vinculando…' : 'Vincular TPV'}
</button>
</form>
</div>
);
}
if (!config) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-gray-500">
<p>{configError || 'Cargando TPV…'}</p>
{configError && (
<button
onClick={() => void loadConfig()}
className="font-medium text-[#2D6A4F] underline"
>
Reintentar
</button>
)}
</div>
);
}
if (config.session?.status !== 'OPEN') {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-6">
<form
onSubmit={openCashSession}
className="w-full max-w-md space-y-5 rounded-2xl bg-white p-8 shadow-lg"
>
<div>
<p className="text-sm font-medium text-[#2D6A4F]">
{config.store.name} · {config.terminal.name}
</p>
<h1 className="mt-1 text-3xl font-bold">Abrir sesión de caja</h1>
<p className="mt-2 text-sm text-gray-500">
Indica el efectivo disponible al comenzar el turno.
</p>
</div>
<label className="block text-sm font-medium">
Efectivo inicial ()
<input
type="number"
min="0"
step="0.01"
value={openingCash}
onChange={(event) => setOpeningCash(event.target.value)}
className="mt-1 w-full rounded-xl border px-4 py-3 text-xl"
required
autoFocus
/>
</label>
{sessionError && <p className="text-sm text-red-600">{sessionError}</p>}
<button
disabled={openingSession}
className="min-h-14 w-full rounded-xl bg-[#2D6A4F] font-semibold text-white disabled:opacity-50"
>
{openingSession ? 'Abriendo…' : 'Abrir caja y comenzar'}
</button>
</form>
</div>
);
}
return (
<div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}>
{!isSelfpayMode && showSidebar && (
<aside
className="hidden w-[380px] shrink-0 flex-col border-r bg-gray-50 p-3 lg:flex"
aria-label="Pedidos"
>
{/* Tab buttons */}
<div className="mb-3 flex gap-1 rounded-xl bg-white p-1 shadow-sm">
<button
type="button"
onClick={() => { setSalesTab('pending'); void loadPendingSales(); }}
className={`flex-1 rounded-lg py-2 text-sm font-bold transition-colors ${
salesTab === 'pending'
? 'bg-[#2D6A4F] text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
Pendientes
{pendingSales.length > 0 && (
<span className={`ml-1 rounded-full px-1.5 py-0.5 text-xs ${
salesTab === 'pending' ? 'bg-white/20 text-white' : 'bg-amber-100 text-amber-700'
}`}>
{pendingSales.length}
</span>
)}
</button>
<button
type="button"
onClick={() => { setSalesTab('day'); void loadTodaySales(); }}
className={`flex-1 rounded-lg py-2 text-sm font-bold transition-colors ${
salesTab === 'day'
? 'bg-[#2D6A4F] text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
Día
{todaySales.length > 0 && (
<span className={`ml-1 rounded-full px-1.5 py-0.5 text-xs ${
salesTab === 'day' ? 'bg-white/20 text-white' : 'bg-green-100 text-green-700'
}`}>
{todaySales.length}
</span>
)}
</button>
</div>
{/* Pending tab */}
{salesTab === 'pending' && (
<>
<div className="mb-3 flex items-center justify-between">
<p className="text-xs text-gray-500">Ventas con saldo pendiente.</p>
<button
type="button"
onClick={() => void loadPendingSales()}
disabled={loadingPending}
title="Actualizar"
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-40"
>
<svg className={loadingPending ? 'animate-spin' : ''} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M23 4v6h-6M1 20v-6h6" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{loadingPending ? (
<p className="text-xs text-gray-500">Cargando</p>
) : pendingSales.length === 0 ? (
<p className="text-xs text-gray-500">Sin ventas pendientes.</p>
) : (
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto">
{pendingSales.map((sale) => {
const saleDate = new Date(sale.createdAt);
const dateStr = saleDate.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
const timeStr = saleDate.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
const displayName = sale.posLabel || sale.receiptNumber || sale.id.slice(0, 8);
return (
<li
key={sale.id}
className="rounded-xl border border-amber-200 bg-white p-3 text-sm shadow-sm"
>
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-gray-800">{displayName}</p>
<p className="text-xs text-gray-400">
{dateStr} {timeStr}
</p>
</div>
<div className="text-right">
<p className="font-bold text-gray-800">{formatPrice(sale.totalCents)}</p>
<p className="text-xs font-medium text-amber-600">
Pdo: {formatPrice(sale.outstandingCents)}
</p>
</div>
</div>
<div className="mt-2 flex gap-1">
<button
type="button"
onClick={() => void recoverSale(sale)}
disabled={recoveringSaleId === sale.id}
className="flex-1 rounded-lg bg-[#2D6A4F] px-2 py-1.5 text-xs font-bold text-white disabled:opacity-50"
>
{recoveringSaleId === sale.id ? '...' : 'Recuperar'}
</button>
<button
type="button"
onClick={() => void reprintSale(sale)}
disabled={printingSaleId === sale.id}
className="flex items-center justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-xs font-bold text-blue-600 hover:bg-blue-100 disabled:opacity-50"
title="Reimprimir ticket"
>
{printingSaleId === sale.id ? '...' : '🖨'}
</button>
<button
type="button"
onClick={() => setDeleteConfirmFor(sale)}
className="flex items-center justify-center rounded-lg bg-red-50 px-2 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100"
title="Eliminar"
>
🗑
</button>
</div>
</li>
);
})}
</ul>
)}
</>
)}
{/* Today's sales tab */}
{salesTab === 'day' && (
<>
<div className="mb-3 flex items-center justify-between">
<p className="text-xs text-gray-500">Ventas completadas hoy.</p>
<button
type="button"
onClick={() => void loadTodaySales()}
title="Actualizar"
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M23 4v6h-6M1 20v-6h6" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{todaySales.length === 0 ? (
<p className="text-xs text-gray-500">Sin ventas hoy.</p>
) : (
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto">
{todaySales
.filter((s) => s.state !== 'PENDING')
.map((sale) => {
const saleDate = new Date(sale.createdAt);
const timeStr = saleDate.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
const displayName = sale.posLabel || sale.receiptNumber || sale.id.slice(0, 8);
const state = sale.state as string;
const isCompleted = state === 'COMPLETED';
const isCancelled = state === 'CANCELLED';
const isRefunded = state === 'REFUNDED' || state === 'PARTIAL_REFUNDED';
const stateStyles = isCancelled
? 'border-red-200 bg-red-50'
: isRefunded
? 'border-purple-200 bg-purple-50'
: 'border-green-200 bg-green-50';
const refundLabel = state === 'REFUNDED' ? '✕ Reintegrada' :
state === 'PARTIAL_REFUNDED' ? '↩ Reintegrada parcialmente' : '';
return (
<li
key={sale.id}
className={`rounded-xl border p-3 text-sm shadow-sm ${stateStyles}`}
>
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-gray-800">{displayName}</p>
<p className="text-xs text-gray-400">{timeStr}</p>
</div>
<div className="text-right">
<p className="font-bold text-gray-800">{formatPrice(sale.totalCents)}</p>
<p className={`text-xs font-medium ${
isCancelled ? 'text-red-600' :
isRefunded ? 'text-purple-600' : 'text-gray-500'
}`}>
{isCompleted ? '✓ Completada' :
isCancelled ? '✕ Cancelada' :
isRefunded ? refundLabel : sale.state}
</p>
</div>
</div>
{isCompleted && (
<div className="mt-2 flex gap-1">
<button
type="button"
onClick={() => void reprintSale(sale)}
disabled={printingSaleId === sale.id}
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-2 py-1.5 text-xs font-bold text-blue-600 hover:bg-blue-100 disabled:opacity-50"
>
{printingSaleId === sale.id ? '...' : '🖨'} Reimprimir
</button>
<button
type="button"
onClick={async () => {
try {
const result = await posApi.getReceipt<{ receipt: PosReceipt }>(sale.id);
setReturnOrder({ orderId: sale.id, receipt: result.receipt });
} catch {
setError('No se pudo cargar el ticket para reintegrar');
}
}}
className="flex items-center justify-center rounded-lg bg-amber-50 px-2 py-1.5 text-xs font-bold text-amber-600 hover:bg-amber-100"
title="Reintegrar"
>
Reintegrar
</button>
</div>
)}
{isRefunded && (
<div className="mt-2 flex gap-1">
<button
type="button"
onClick={() => void reprintSale(sale)}
disabled={printingSaleId === sale.id}
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-2 py-1.5 text-xs font-bold text-blue-600 hover:bg-blue-100 disabled:opacity-50"
>
{printingSaleId === sale.id ? '...' : '🖨'} Ver ticket
</button>
<button
type="button"
onClick={async () => {
try {
const [receiptResult, itemsResult] = await Promise.all([
posApi.getReceipt<{ receipt: PosReceipt }>(sale.id),
posApi.listOrderItems<{ items: RecoveredOrderItem[] }>(sale.id),
]);
// Show which items were returned
const returnedItems = itemsResult.items.filter((i) => i.returnedQuantity > 0);
if (returnedItems.length === 0) {
setError('No hay artículos reintegrados en este ticket');
} else {
setReturnOrder({ orderId: sale.id, receipt: receiptResult.receipt });
}
} catch {
setError('No se pudo cargar el detalle del reintegro');
}
}}
className="flex items-center justify-center rounded-lg bg-purple-50 px-2 py-1.5 text-xs font-bold text-purple-600 hover:bg-purple-100"
title="Ver reintegros"
>
📋 Ver reintegrado
</button>
</div>
)}
{isCancelled && (
<p className="mt-2 text-xs text-red-500">Ticket cancelado no imprimible</p>
)}
</li>
);
})}
</ul>
)}
</>
)}
</aside>
)}
{/* POS-FIX-4: product added toast */}
{addedToast && (
<div className="pointer-events-none fixed top-6 right-6 z-50 animate-in slide-in-from-top-2 fade-in duration-200">
<div className="flex items-center gap-2 rounded-xl bg-green-600 px-4 py-3 text-sm font-bold text-white shadow-lg">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{addedToast}
</div>
</div>
)}
<main className="flex min-w-0 flex-1 flex-col overflow-hidden border-r p-4">
<div className="mb-3">
<div className="mb-2 flex items-center gap-3">
{/* Panel toggle button - LEFT */}
{!isSelfpayMode && (
<button
type="button"
onClick={() => setShowSidebar((v) => !v)}
title={showSidebar ? 'Ocultar panel' : 'Mostrar panel'}
className="flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d={showSidebar ? "M4 6h16M4 12h16M4 18h7" : "M4 6h16M4 12h16M4 18h16"} />
</svg>
{showSidebar ? 'Ocultar' : 'Panel'}
</button>
)}
{/* Store name */}
<span className="text-sm font-bold text-[#2D6A4F]">{config.store.name}</span>
{/* Status badge */}
<span className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
Caja abierta
</span>
{/* Clock */}
<span className="ml-auto flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-gray-700 border border-gray-200">
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{currentTime.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
<span className="text-gray-400">·</span>
{currentTime.toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
{!isSelfpayMode && (
<button
type="button"
onClick={() => {
setClosingActualCash('');
setClosingPin('');
setClosingPinError('');
setClosingPinStep(false);
setCloseError('');
setShowCloseSession(true);
}}
className="flex items-center gap-1.5 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Cerrar caja
</button>
)}
</div>
<div className="flex gap-2">
<input
value={search}
onChange={(event) => 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
/>
<button
type="button"
onClick={() => setShowFreeItem(true)}
className="min-h-12 rounded-xl border-2 border-[#2D6A4F] px-4 font-bold text-[#2D6A4F]"
>
Artículo libre
</button>
</div>
</div>
{!search.trim() && touchCatalog?.enabled && (
<div className="flex-1 overflow-y-auto pb-3">
<div className="mb-3 flex items-center gap-2">
<button
type="button"
disabled={categoryPath.length === 0}
onClick={() => setCategoryPath((path) => path.slice(0, -1))}
className="min-h-12 rounded-xl border px-4 font-semibold disabled:opacity-30"
>
Atrás
</button>
<button
type="button"
onClick={() => setCategoryPath([])}
className="font-semibold text-[#2D6A4F]"
>
Categorías
</button>
{categoryPath.map((category, index) => (
<span key={category.id} className="text-sm text-gray-500">
{' '}
<button
type="button"
onClick={() => setCategoryPath((path) => path.slice(0, index + 1))}
>
{category.name}
</button>
</span>
))}
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{visibleCategories.map((category) => (
<button
key={category.id}
type="button"
onClick={() => setCategoryPath((path) => [...path, category])}
className="min-h-24 rounded-2xl border border-black/5 p-4 text-left text-lg font-bold shadow-sm active:scale-95"
style={{
backgroundColor: category.bgColor || '#eef7e8',
color: category.textColor || '#2D6A4F',
}}
>
<span className="mr-2 text-2xl">{category.emoji || '📁'}</span>
{category.name}
</button>
))}
{visibleTouchProducts.map((product) => (
<button
key={`${currentCategory?.id}-${product.variantId}`}
type="button"
onClick={() => addToCart(product)}
className="min-h-24 rounded-2xl border-2 border-gray-100 bg-white p-4 text-left shadow-sm active:scale-95"
>
<span className="block font-bold">{product.name}</span>
<span className="mt-2 flex justify-between text-sm">
<span>{product.stock} uds</span>
<strong className="text-[#2D6A4F]">{formatPrice(product.priceCents)}</strong>
</span>
</button>
))}
</div>
</div>
)}
{searchResults.length > 0 && (
<div className="flex-1 space-y-2 overflow-y-auto">
{searchResults.map((product) => (
<button
key={product.variantId}
onClick={() => addToCart(product)}
className="flex w-full items-center justify-between rounded-xl bg-white p-3 text-left shadow-sm"
>
<div>
<p className="font-medium">{product.name}</p>
<p className="text-xs text-gray-400">
{product.sku}
{product.ean ? ` · ${product.ean}` : ''}
</p>
</div>
<div className="text-right">
<p className="font-bold text-[#2D6A4F]">{formatPrice(product.priceCents)}</p>
<p
className={
product.stock > 0 ? 'text-xs text-green-600' : 'text-xs text-red-500'
}
>
{product.stock > 0 ? `${product.stock} uds` : 'Sin stock'}
</p>
</div>
</button>
))}
</div>
)}
{searching && <p className="py-4 text-center text-gray-400">Buscando</p>}
{searchError && <p className="py-4 text-center text-red-600">{searchError}</p>}
{!search.trim() && touchCatalog?.enabled && (
<div
className="grid shrink-0 grid-cols-4 gap-2 border-t pt-3"
aria-label="Productos rápidos"
>
{Array.from({ length: 8 }, (_, slot) => touchCatalog.quickProducts[slot] ?? null).map(
(product, slot) =>
product ? (
<button
key={`${slot}-${product.variantId}`}
type="button"
onClick={() => addToCart(product)}
className="min-h-20 rounded-xl bg-[#2D6A4F] px-2 py-2 text-sm font-bold text-white shadow active:scale-95"
>
<span className="line-clamp-2">{product.name}</span>
<span className="mt-1 block text-xs text-white/80">
{formatPrice(product.priceCents)}
</span>
</button>
) : (
<div
key={slot}
className="flex min-h-20 items-center justify-center rounded-xl border-2 border-dashed text-xs text-gray-400"
>
Rápido {slot + 1}
</div>
),
)}
</div>
)}
</main>
<aside className="flex w-[440px] flex-col bg-gray-50 p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-bold text-[#2D6A4F]">Cashier</h2>
{customer ? (
<button
type="button"
onClick={() => setCustomer(null)}
className="min-h-10 rounded-full bg-blue-100 px-3 text-xs text-blue-700"
>
{customer.displayName || customer.email}
</button>
) : (
<button
type="button"
onClick={() => setShowCustomerSearch((value) => !value)}
className="min-h-10 rounded-xl px-3 text-sm font-semibold text-blue-600"
>
Cliente
</button>
)}
</div>
{showCustomerSearch && (
<div className="mb-3 space-y-3 rounded-xl bg-white p-3 shadow">
<div className="flex rounded-lg bg-gray-100 p-1 text-xs font-medium">
<button
type="button"
onClick={() => setCustomerMode('search')}
className={`flex-1 rounded-md py-2 ${customerMode === 'search' ? 'bg-white text-[#2D6A4F] shadow-sm' : ''}`}
>
Buscar
</button>
<button
type="button"
onClick={() => setCustomerMode('create')}
className={`flex-1 rounded-md py-2 ${customerMode === 'create' ? 'bg-white text-[#2D6A4F] shadow-sm' : ''}`}
>
Crear
</button>
</div>
{customerMode === 'search' ? (
<>
<input
type="search"
value={customerQuery}
onChange={(event) => {
void searchCustomers(event.target.value);
}}
placeholder="Email, nombre o teléfono…"
className="min-h-11 w-full rounded-lg border px-3"
/>
<div className="max-h-32 overflow-y-auto">
{customerResults.map((result) => (
<button
key={result.id}
type="button"
onClick={() => {
setCustomer(result);
setShowCustomerSearch(false);
}}
className="w-full rounded-lg p-2 text-left text-sm hover:bg-blue-50"
>
<strong className="block">{result.displayName || result.email}</strong>
{result.displayName && (
<span className="text-xs text-gray-500">{result.email}</span>
)}
</button>
))}
</div>
</>
) : (
<form onSubmit={createCustomer} className="space-y-2">
<input
type="email"
required
value={newCustomer.email}
onChange={(event) =>
setNewCustomer({ ...newCustomer, email: event.target.value })
}
placeholder="Email *"
className="min-h-11 w-full rounded-lg border px-3"
/>
<input
value={newCustomer.displayName}
onChange={(event) =>
setNewCustomer({ ...newCustomer, displayName: event.target.value })
}
placeholder="Nombre"
className="min-h-11 w-full rounded-lg border px-3"
/>
<input
value={newCustomer.phone}
onChange={(event) =>
setNewCustomer({ ...newCustomer, phone: event.target.value })
}
placeholder="Teléfono"
className="min-h-11 w-full rounded-lg border px-3"
/>
<button
disabled={creatingCustomer}
className="min-h-11 w-full rounded-lg bg-[#2D6A4F] font-semibold text-white"
>
{creatingCustomer ? 'Creando…' : 'Crear y asociar'}
</button>
</form>
)}
{customerError && <p className="text-xs text-red-600">{customerError}</p>}
</div>
)}
<div className="flex-1 space-y-2 overflow-y-auto">
{cart.length === 0 && (
<p className="py-12 text-center text-gray-400">Añade productos o un artículo libre.</p>
)}
{cart.map((item) => (
<div key={item.lineId} className="rounded-xl bg-white p-3 shadow-sm">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium">{item.name}</p>
<p className="text-xs text-gray-400">
{formatPrice(item.unitPriceCents - item.discountCents)}/ud{' '}
{item.kind === 'free' ? '· libre' : ''}
</p>
</div>
<button
type="button"
onClick={() => removeFromCart(item.lineId)}
className="min-h-10 min-w-10 rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-600"
aria-label={`Eliminar ${item.name}`}
>
</button>
</div>
<div className="mt-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => updateQuantity(item.lineId, item.quantity - 1)}
className="min-h-10 min-w-10 rounded-lg bg-gray-100 text-lg font-bold"
>
</button>
<span className="w-7 text-center font-medium">{item.quantity}</span>
<button
type="button"
onClick={() => updateQuantity(item.lineId, item.quantity + 1)}
className="min-h-10 min-w-10 rounded-lg bg-gray-100 text-lg font-bold"
>
</button>
</div>
<div className="flex items-center gap-2">
{lineDiscountsEnabled && item.kind === 'stock' && (
<button
type="button"
onClick={() => {
setSelectedItem(item);
setShowDiscountPanel(true);
}}
className={`min-h-10 rounded-lg px-3 text-xs font-bold ${item.discountCents > 0 ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700'}`}
>
{item.discountCents > 0
? `Dto. ${formatPrice(item.discountCents)}`
: 'Descuento'}
</button>
)}
<strong>
{formatPrice(
(item.unitPriceCents - item.discountCents + item.taxCents) * item.quantity,
)}
</strong>
</div>
</div>
</div>
))}
</div>
<div className="mt-3 space-y-1 border-t pt-3 text-sm">
<div className="flex justify-between">
<span>Subtotal</span>
<span>{formatPrice(totals.subtotal)}</span>
</div>
{!isSelfpayMode && totals.discount > 0 && (
<div className="flex justify-between text-red-600">
<span>Descuentos</span>
<span>{formatPrice(totals.discount)}</span>
</div>
)}
<div className="flex justify-between">
<span>IVA</span>
<span>{formatPrice(totals.tax)}</span>
</div>
<div className="flex justify-between text-xl font-bold">
<span>Total</span>
<span className="text-[#2D6A4F]">{formatPrice(totals.total)}</span>
</div>
</div>
{payments.length > 0 && (
<section className="mt-3 rounded-xl border border-blue-200 bg-blue-50 p-3">
<div className="mb-2 flex justify-between text-sm font-bold text-blue-900">
<span>Pagado {formatPrice(paidCents)}</span>
<span>Pendiente {formatPrice(remainingCents)}</span>
</div>
{payments.map((payment) => (
<div
key={payment.id}
className="flex items-center justify-between border-t border-blue-100 py-2 text-sm"
>
<span>
{payment.methodLabel}
{payment.changeCents > 0 ? ` · cambio ${formatPrice(payment.changeCents)}` : ''}
</span>
<span className="flex items-center gap-2">
<strong>{formatPrice(payment.amountCents)}</strong>
<button
type="button"
onClick={() =>
setPayments((current) => current.filter((item) => item.id !== payment.id))
}
className="min-h-9 min-w-9 rounded-lg bg-white text-red-600"
aria-label={`Quitar pago ${payment.methodLabel}`}
>
</button>
</span>
</div>
))}
</section>
)}
{error && (
<p className="mt-2 text-center text-sm font-medium text-red-600" aria-live="polite">
{error}
</p>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
{config.paymentMethods.map((method) => (
<button
key={method.id}
type="button"
onClick={() => setPaymentMethod(method)}
disabled={cart.length === 0 || processing || remainingCents === 0}
className={`min-h-14 rounded-xl px-2 font-bold text-white disabled:opacity-40 ${method.kind === 'cash' ? 'bg-green-600' : method.kind === 'card' ? 'bg-blue-600' : 'bg-slate-700'}`}
>
{method.kind === 'cash' ? '💵' : method.kind === 'card' ? '💳' : '◉'} {method.label}
</button>
))}
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => void parkSale()}
disabled={processing || cart.length === 0}
className="min-h-16 rounded-xl bg-amber-500 text-lg font-bold text-white disabled:opacity-40"
>
{processing ? 'Guardando…' : 'Guardar pendiente'}
</button>
<button
type="button"
onClick={() => void confirmSale()}
disabled={
processing ||
cart.length === 0 ||
payments.length === 0 ||
paidCents <= 0 ||
paidCents > totals.total
}
className="min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40"
>
{processing ? 'Confirmando…' : 'Cobrar e imprimir'}
</button>
</div>
<button
type="button"
onClick={() => setShowClearConfirm(true)}
disabled={processing || cart.length === 0}
className="mt-2 w-full rounded-xl border border-gray-300 bg-white py-2 text-sm font-bold text-red-600 disabled:opacity-40"
>
Vaciar caja
</button>
</aside>
{!isSelfpayMode && showDiscountPanel && selectedItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<DiscountPanel
unitPriceCents={selectedItem.unitPriceCents}
onApply={(discountCents) => {
applyDiscount(selectedItem.lineId, discountCents);
setShowDiscountPanel(false);
setSelectedItem(null);
}}
onClose={() => {
setShowDiscountPanel(false);
setSelectedItem(null);
}}
/>
</div>
)}
{showFreeItem && <FreeItemModal onAdd={addFreeItem} onClose={() => setShowFreeItem(false)} />}
{paymentMethod && (
<PaymentModal
method={paymentMethod}
remainingCents={remainingCents}
onAdd={addPayment}
onClose={() => setPaymentMethod(null)}
/>
)}
{restPaymentFor && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="rest-payment-title"
>
<div className="w-full max-w-md space-y-4 rounded-2xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-amber-600">
Pendiente {formatPrice(restPaymentFor.outstandingCents)}
</p>
<h2 id="rest-payment-title" className="text-2xl font-bold text-gray-900">
Cobrar resto
</h2>
<p className="mt-1 text-xs text-gray-500">
Venta {restPaymentFor.receiptNumber ?? restPaymentFor.id.slice(0, 8)}
</p>
</div>
<button
type="button"
onClick={() => {
if (!processingRest) setRestPaymentFor(null);
}}
aria-label="Cerrar"
className="text-2xl text-gray-400 hover:text-gray-700"
>
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{config.paymentMethods.map((method) => (
<button
key={method.id}
type="button"
onClick={() => setPaymentMethod(method)}
disabled={processingRest}
className={`min-h-14 rounded-xl px-2 font-bold text-white disabled:opacity-40 ${method.kind === 'cash' ? 'bg-green-600' : method.kind === 'card' ? 'bg-blue-600' : 'bg-slate-700'}`}
>
{method.kind === 'cash' ? '💵' : method.kind === 'card' ? '💳' : '◉'} {method.label}
</button>
))}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
</div>
)}
{paymentMethod && restPaymentFor && (
<PaymentModal
method={paymentMethod}
remainingCents={restPaymentFor.outstandingCents}
onAdd={(allocation) => {
void submitRestPayment(allocation);
}}
onClose={() => setPaymentMethod(null)}
/>
)}
{/* POS-FIX-7: confirmar antes de vaciar */}
{showClearConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-xs rounded-2xl bg-white p-6 shadow-xl">
<h2 className="mb-2 text-lg font-bold text-gray-900">¿Vaciar ticket?</h2>
<p className="mb-4 text-sm text-gray-600">
Se borrarán {cart.length} {cart.length === 1 ? 'artículo' : 'artículos'} del ticket actual.
Esta acción no se puede deshacer.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowClearConfirm(false)}
className="flex-1 rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancelar
</button>
<button
type="button"
onClick={() => {
setShowClearConfirm(false);
resetCashier();
}}
className="flex-1 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700"
>
Vaciar
</button>
</div>
</div>
</div>
)}
{/* POS-FIX-11: name dialog when parking without customer */}
{pendingParkName !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-xs rounded-2xl bg-white p-6 shadow-xl">
<h2 className="mb-2 text-lg font-bold text-gray-900">Nombre para identificar</h2>
<p className="mb-4 text-sm text-gray-500">
¿A nombre de quién es este ticket pendiente?
</p>
<input
type="text"
value={pendingParkName}
onChange={(e) => 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());
}
}}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPendingParkName(null)}
className="flex-1 rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancelar
</button>
<button
type="button"
onClick={() => {
if (pendingParkName.trim()) {
void doParkSale(pendingParkName.trim());
}
}}
disabled={!pendingParkName.trim()}
className="flex-1 rounded-xl bg-[#2D6A4F] px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
>
Guardar
</button>
</div>
</div>
</div>
)}
{/* POS-FIX-8: delete confirmation for pending sale */}
{deleteConfirmFor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-xs rounded-2xl bg-white p-6 shadow-xl">
<h2 className="mb-2 text-lg font-bold text-gray-900">¿Eliminar venta?</h2>
<p className="mb-1 text-sm text-gray-600">
Ticket {deleteConfirmFor.receiptNumber ?? deleteConfirmFor.id.slice(0, 8)}
</p>
<p className="mb-4 text-sm text-gray-500">
Total {formatPrice(deleteConfirmFor.totalCents)} · pendiente {formatPrice(deleteConfirmFor.outstandingCents)}
</p>
<p className="mb-4 text-xs text-amber-600">
Se liberará el stock reservado y no se podrá deshacer.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => setDeleteConfirmFor(null)}
className="flex-1 rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancelar
</button>
<button
type="button"
onClick={() => void deleteSale(deleteConfirmFor)}
className="flex-1 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700"
>
Eliminar
</button>
</div>
{error && <p className="mt-2 text-center text-xs text-red-600">{error}</p>}
</div>
</div>
)}
{/* POS-FIX-9: merge dialog when recovering a sale with items in cart */}
{mergePendingSale && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-xs rounded-2xl bg-white p-6 shadow-xl">
<h2 className="mb-2 text-lg font-bold text-gray-900">Ticket con artículos</h2>
<p className="mb-1 text-sm text-gray-600">
Hay {cart.length} {cart.length === 1 ? 'artículo' : 'artículos'} en el ticket actual.
</p>
<p className="mb-4 text-sm text-gray-500">
¿Qué deseas hacer con la venta {mergePendingSale.receiptNumber ?? mergePendingSale.id.slice(0, 8)}?
</p>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => {
setError('');
void parkAndRecover().catch((err) => setError(err instanceof Error ? err.message : String(err)));
}}
disabled={recoveringSaleId !== null}
className="flex-1 rounded-xl border border-amber-300 bg-amber-50 px-4 py-2.5 text-sm font-bold text-amber-700 hover:bg-amber-100 disabled:opacity-50"
>
💾 Dejar actual pendiente y recuperar
</button>
<button
type="button"
onClick={() => {
setError('');
void mergeRecoveredItems().catch((err) => setError(err instanceof Error ? err.message : String(err)));
}}
disabled={recoveringSaleId !== null}
className="flex-1 rounded-xl border border-green-300 bg-green-50 px-4 py-2.5 text-sm font-bold text-green-700 hover:bg-green-100 disabled:opacity-50"
>
Añadir al ticket actual
</button>
<button
type="button"
onClick={() => {
setMergePendingSale(null);
}}
className="flex-1 rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancelar
</button>
</div>
{error && <p className="mt-2 text-center text-xs text-red-600">{error}</p>}
</div>
</div>
)}
{/* POS-FIX-3/POS-FIX-5: close session — 2-step: PIN then cash */}
{showCloseSession && (
(config?.terminal?.settings?.closeSessionRequiresPin && !closingPinStep) ? (
// STEP 1: PIN
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-xs rounded-2xl bg-white p-6 shadow-xl">
<h2 className="mb-2 text-lg font-bold text-gray-900">Cerrar caja</h2>
<p className="mb-4 text-sm text-gray-500">Introduce tu PIN de cajero para autorizar el cierre.</p>
<input
type="password"
inputMode="numeric"
maxLength={6}
value={closingPin}
onChange={(e) => { 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 && <p className="mb-3 text-xs text-red-600">{closingPinError}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setShowCloseSession(false); setClosingPinStep(false); }}
className="flex-1 rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancelar
</button>
<button
type="button"
onClick={() => void handleVerifyPin()}
className="flex-1 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700"
>
Confirmar PIN
</button>
</div>
</div>
</div>
) : (
// STEP 2: cash amount
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
<h2 className="mb-4 text-lg font-bold text-gray-900">Cerrar caja</h2>
<p className="mb-4 text-sm text-gray-600">
Indica el efectivo real en caja para calcular la diferencia.
</p>
<label className="mb-1 block text-sm font-medium text-gray-700">
Efectivo real (céntimos)
</label>
<input
type="number"
min="0"
value={closingActualCash}
onChange={(e) => 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 && <p className="mb-3 text-xs text-red-600">{closeError}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setShowCloseSession(false); setClosingPinStep(false); }}
disabled={closing}
className="flex-1 rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
Cancelar
</button>
<button
type="button"
onClick={() => void handleCloseSession()}
disabled={closing}
className="flex-1 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-50"
>
{closing ? 'Cerrando…' : 'Confirmar cierre'}
</button>
</div>
</div>
</div>
)
)}
{receipt && (
<ReceiptModal
receipt={receipt}
initialEmail={customer?.email ?? receipt.customerEmail ?? ''}
onDelivered={resetCashier}
onReturn={
config?.session && config?.terminal
? (orderId, latest) => {
setReturnOrder({ orderId, receipt: latest });
}
: undefined
}
/>
)}
{returnOrder && config?.session && config?.terminal && (
<ReturnModal
receipt={returnOrder.receipt}
orderId={returnOrder.orderId}
cashSessionId={config.session.id}
terminalId={config.terminal.id}
onClose={() => setReturnOrder(null)}
onReturned={(latest) => {
setReturnOrder(null);
setReceipt(latest);
}}
/>
)}
</div>
);
}