'use client'; import { useCallback, useEffect, useState } from 'react'; import { api } from '@/lib/api-client'; interface PosStore { id: string; name: string; active: boolean; } interface PosTerminal { id: string; storeId: string; name: string; bindingCode: string | null; boundAt: string | null; status: string; settings?: { touchNavigationEnabled?: boolean; quickProductVariantIds?: Array; lineDiscountsEnabled?: boolean; }; } interface CatalogOption { variantId: string; name: string; sku: string; } interface PaymentMethod { id: string; storeId: string; code: string; label: string; kind: 'cash' | 'card' | 'other'; active: boolean; sortOrder: number; } interface PosCashier { id: string; email: string; role: 'pos_cashier'; active: boolean; deactivatedAt: string | null; deletedAt: string | null; createdAt: string; status: 'active' | 'inactive' | 'deleted'; } interface ReceiptSettings { storeId: string; name: string; address: string; taxId: string; contactEmail: string; contactPhone: string; receiptHeader: string; receiptFooter: string; prefix: string; nextNumber: number; padding: number; returnPolicy: string; } const emptyReceipt: ReceiptSettings = { storeId: '', name: '', address: '', taxId: '', contactEmail: '', contactPhone: '', receiptHeader: '', receiptFooter: '', prefix: 'TPV', nextNumber: 1, padding: 6, returnPolicy: 'Se admiten devoluciones durante 15 días con el ticket de compra.', }; export default function PosAdminPage() { const [stores, setStores] = useState([]); const [terminals, setTerminals] = useState([]); const [catalogOptions, setCatalogOptions] = useState([]); const [storeId, setStoreId] = useState(''); const [terminalName, setTerminalName] = useState(''); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [error, setError] = useState(''); const [newCode, setNewCode] = useState(''); const [copied, setCopied] = useState(''); const [configuring, setConfiguring] = useState(null); const [touchEnabled, setTouchEnabled] = useState(true); const [discountsEnabled, setDiscountsEnabled] = useState(true); const [quickSlots, setQuickSlots] = useState>( Array.from({ length: 8 }, () => null), ); const [savingTouch, setSavingTouch] = useState(false); const [touchMessage, setTouchMessage] = useState(''); const [paymentMethods, setPaymentMethods] = useState([]); const [cashiers, setCashiers] = useState([]); const [newCashier, setNewCashier] = useState({ email: '', password: '' }); const [savingCashier, setSavingCashier] = useState(false); const [cashierMessage, setCashierMessage] = useState(''); const [newMethod, setNewMethod] = useState({ code: '', label: '', kind: 'other' as PaymentMethod['kind'], }); const [savingMethod, setSavingMethod] = useState(false); const [paymentMessage, setPaymentMessage] = useState(''); const [receipt, setReceipt] = useState(emptyReceipt); const [savingReceipt, setSavingReceipt] = useState(false); const [receiptMessage, setReceiptMessage] = useState(''); const loadStoreConfiguration = useCallback(async (selectedStoreId: string) => { if (!selectedStoreId) return; try { const [methodData, receiptData] = await Promise.all([ api.get<{ items: PaymentMethod[] }>( `/api/pos/admin/payment-methods?storeId=${encodeURIComponent(selectedStoreId)}`, ), api.get>( `/api/pos/admin/receipt-settings?storeId=${encodeURIComponent(selectedStoreId)}`, ), ]); setPaymentMethods(methodData.items); setReceipt({ ...emptyReceipt, ...receiptData, storeId: selectedStoreId }); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo cargar la configuración de cobro'); } }, []); const load = useCallback(async () => { setLoading(true); setError(''); try { const [storeData, terminalData, catalogData, userData] = await Promise.all([ api.get<{ stores: PosStore[] }>('/api/pos/admin/stores'), api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'), api.get<{ items: CatalogOption[] }>('/api/pos/admin/catalog-products'), api.get<{ items: Array }>('/api/pos/users'), ]); setStores(storeData.stores); setTerminals(terminalData.terminals); setCatalogOptions(catalogData.items); setCashiers(userData.items.filter((item): item is PosCashier => item.role === 'pos_cashier')); const selected = storeId || storeData.stores.find((store) => store.active)?.id || ''; setStoreId(selected); await loadStoreConfiguration(selected); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo cargar la configuración TPV'); } finally { setLoading(false); } }, [loadStoreConfiguration, storeId]); useEffect(() => { void load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps const changeStore = (nextStoreId: string) => { setStoreId(nextStoreId); setReceiptMessage(''); setPaymentMessage(''); void loadStoreConfiguration(nextStoreId); }; const createTerminal = async (event: React.FormEvent) => { event.preventDefault(); setCreating(true); setError(''); setNewCode(''); try { const terminal = await api.post('/api/pos/admin/terminals', { storeId, name: terminalName.trim(), }); setTerminalName(''); setNewCode(terminal.bindingCode ?? ''); await load(); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo crear el terminal'); } finally { setCreating(false); } }; const openTouchConfig = (terminal: PosTerminal) => { setConfiguring(terminal); setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false); setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false); const configured = terminal.settings?.quickProductVariantIds ?? []; setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null)); setTouchMessage(''); }; const saveTouchConfig = async () => { if (!configuring) return; setSavingTouch(true); setTouchMessage(''); try { await api.patch(`/api/pos/admin/terminals/${configuring.id}/touch-config`, { touchNavigationEnabled: touchEnabled, lineDiscountsEnabled: discountsEnabled, quickProductVariantIds: quickSlots, }); setTouchMessage('Configuración del terminal guardada'); await load(); } catch (err) { setTouchMessage(err instanceof Error ? err.message : 'No se pudo guardar'); } finally { setSavingTouch(false); } }; const createPaymentMethod = async (event: React.FormEvent) => { event.preventDefault(); setSavingMethod(true); setPaymentMessage(''); try { await api.post('/api/pos/admin/payment-methods', { storeId, code: newMethod.code.trim().toLowerCase(), label: newMethod.label.trim(), kind: newMethod.kind, active: true, sortOrder: paymentMethods.length, }); setNewMethod({ code: '', label: '', kind: 'other' }); setPaymentMessage('Forma de pago creada'); await loadStoreConfiguration(storeId); } catch (err) { setPaymentMessage(err instanceof Error ? err.message : 'No se pudo crear la forma de pago'); } finally { setSavingMethod(false); } }; const togglePaymentMethod = async (method: PaymentMethod) => { setPaymentMessage(''); try { await api.patch(`/api/pos/admin/payment-methods/${method.id}`, { active: !method.active }); setPaymentMessage(method.active ? 'Forma de pago desactivada' : 'Forma de pago activada'); await loadStoreConfiguration(storeId); } catch (err) { setPaymentMessage(err instanceof Error ? err.message : 'No se pudo actualizar'); } }; const createCashier = async (event: React.FormEvent) => { event.preventDefault(); setSavingCashier(true); setCashierMessage(''); try { await api.post('/api/pos/users', { email: newCashier.email.trim().toLowerCase(), password: newCashier.password, role: 'pos_cashier', }); setNewCashier({ email: '', password: '' }); setCashierMessage('Cajero creado'); await load(); } catch (err) { setCashierMessage(err instanceof Error ? err.message : 'No se pudo crear el cajero'); } finally { setSavingCashier(false); } }; const toggleCashier = async (cashier: PosCashier) => { const nextActive = !cashier.active; const action = nextActive ? 'reactivar' : 'desactivar'; if (!window.confirm(`¿Quieres ${action} a ${cashier.email}?`)) return; setCashierMessage(''); try { await api.patch(`/api/pos/users/${cashier.id}/status`, { active: nextActive }); setCashierMessage(nextActive ? 'Cajero reactivado' : 'Cajero desactivado'); await load(); } catch (err) { setCashierMessage(err instanceof Error ? err.message : `No se pudo ${action} el cajero`); } }; const deleteCashier = async (cashier: PosCashier) => { if ( !window.confirm( `¿Eliminar definitivamente a ${cashier.email}? No podrá reactivarse. Su historial de ventas y caja se conservará.`, ) ) return; setCashierMessage(''); try { await api.delete(`/api/pos/users/${cashier.id}`); setCashierMessage('Cajero eliminado; su histórico se conserva'); await load(); } catch (err) { setCashierMessage(err instanceof Error ? err.message : 'No se pudo eliminar el cajero'); } }; const saveReceipt = async (event: React.FormEvent) => { event.preventDefault(); setSavingReceipt(true); setReceiptMessage(''); try { await api.patch('/api/pos/admin/receipt-settings', receipt); setReceiptMessage('Datos y numeración del ticket guardados'); await loadStoreConfiguration(storeId); } catch (err) { setReceiptMessage(err instanceof Error ? err.message : 'No se pudo guardar el ticket'); } finally { setSavingReceipt(false); } }; const copyCode = async (code: string) => { await navigator.clipboard.writeText(code); setCopied(code); window.setTimeout(() => setCopied(''), 2000); }; return (

TPV

Configura terminales táctiles, formas de pago y contenido fiscal del ticket.

Crear y vincular terminal

{newCode && (

Código: {newCode}

)} {error &&

{error}

}

Terminales

{loading ? (

Cargando…

) : (
{terminals .filter((terminal) => terminal.storeId === storeId) .map((terminal) => ( ))}
Nombre Código Estado Vinculado Configuración
{terminal.name} {terminal.status} {terminal.boundAt ? 'Sí' : 'No'}
)}

Cajeros

Desactivar corta el acceso y permite reactivarlo. Eliminar es irreversible, pero conserva la atribución histórica de ventas y sesiones de caja.

{cashiers.map((cashier) => ( ))} {!loading && cashiers.length === 0 && ( )}
Email Estado Creado Acciones
{cashier.email} {cashier.status === 'active' ? 'Activo' : cashier.status === 'inactive' ? 'Inactivo' : 'Eliminado'} {new Date(cashier.createdAt).toLocaleDateString('es-ES')} {cashier.status !== 'deleted' && (
)}
No hay cajeros configurados.
{cashierMessage &&

{cashierMessage}

}
{configuring && (

Interfaz táctil · {configuring.name}

Elige hasta ocho productos rápidos y las funciones disponibles en esta caja.

Desactiva descuentos para terminales de autopago.

{quickSlots.map((variantId, slot) => ( ))}
{touchMessage &&

{touchMessage}

}
)}

Formas de pago

Crea Bizum, Stripe, Apple Pay u otro método. “Tarjeta” y “otro” se registran manualmente; no activan una pasarela externa.

{paymentMethods.map((method) => (
{method.label} {method.code} · {method.kind}
))}
{paymentMessage &&

{paymentMessage}

}

Empresa, numeración y política del ticket

Estos datos se congelan en cada ticket emitido.

setReceipt({ ...receipt, name: value })} required /> setReceipt({ ...receipt, taxId: value })} /> setReceipt({ ...receipt, address: value })} /> setReceipt({ ...receipt, contactEmail: value })} /> setReceipt({ ...receipt, contactPhone: value })} /> setReceipt({ ...receipt, receiptHeader: value })} /> setReceipt({ ...receipt, receiptFooter: value })} /> setReceipt({ ...receipt, prefix: value.toUpperCase().replace(/[^A-Z0-9_-]/g, '') }) } required /> setReceipt({ ...receipt, nextNumber: Number(value) })} required min={1} /> setReceipt({ ...receipt, padding: Number(value) })} required min={1} max={12} />