2051 lines
80 KiB
TypeScript
2051 lines
80 KiB
TypeScript
'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>
|
||
);
|
||
}
|