feat(POS-007): completed feature

This commit is contained in:
chattie
2026-08-22 13:45:21 +02:00
parent c089a4073a
commit f5f6e8ed72
10 changed files with 379 additions and 24 deletions

View File

@@ -1,33 +1,321 @@
'use client';
import { useEffect, useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { posApi } from '@/lib/api-client';
import { formatPrice } from '@/lib/money';
import { generateIdempotencyKey } from '@/lib/idempotency';
import DiscountPanel from '@/components/DiscountPanel';
export default function TerminalPage() {
const [status, setStatus] = useState<'loading' | 'bound' | 'no-session'>('loading');
interface CartItem {
variantId: string;
productId: string;
sku: string;
name: string;
ean: string | null;
unitPriceCents: number;
discountCents: number;
taxCents: number;
quantity: number;
stock: number;
}
interface SearchResult {
variantId: string; productId: string; name: string; sku: string; ean: string | null;
stock: number; priceCents: number; category: string | null; brand: string | null;
}
interface Config {
session: { id: string; storeId: string; status: string } | null;
terminal: { id: string; name: string };
store: { id: string; name: string };
paymentMethods: { id: string; code: string; label: string; kind: string }[];
}
export default function RegisterPage() {
const [config, setConfig] = useState<Config | null>(null);
const [cart, setCart] = useState<CartItem[]>([]);
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [selectedItem, setSelectedItem] = useState<CartItem | null>(null);
const [showDiscountPanel, setShowDiscountPanel] = useState(false);
const [customer, setCustomer] = useState<{ id: string; email: string; firstName?: string; lastName?: string } | null>(null);
const [showCustomerSearch, setShowCustomerSearch] = useState(false);
const [customerQuery, setCustomerQuery] = useState('');
const [customerResults, setCustomerResults] = useState<typeof customer[]>([]);
const [processing, setProcessing] = useState(false);
const [lastSale, setLastSale] = useState<{ orderId: string; totalCents: number } | null>(null);
const [error, setError] = useState('');
useEffect(() => {
posApi.me()
.then(() => setStatus('bound'))
.catch(() => setStatus('no-session'));
posApi.config().then(setConfig).catch(() => setConfig(null));
}, []);
if (status === 'loading') {
const doSearch = useCallback(async (q: string) => {
if (q.trim().length < 2) { setSearchResults([]); return; }
setSearching(true);
try {
const res = await posApi.searchProducts(q, config?.session?.storeId) as { items: SearchResult[] };
setSearchResults(res.items ?? []);
} catch { setSearchResults([]); }
finally { setSearching(false); }
}, [config]);
useEffect(() => {
const t = setTimeout(() => doSearch(search), 300);
return () => clearTimeout(t);
}, [search, doSearch]);
const addToCart = (product: SearchResult) => {
const existing = cart.find(i => i.variantId === product.variantId);
if (existing) {
setCart(cart.map(i => i.variantId === product.variantId ? { ...i, quantity: i.quantity + 1 } : i));
} else {
setCart([...cart, {
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,
}]);
}
setSearch('');
setSearchResults([]);
};
const removeFromCart = (variantId: string) => {
setCart(cart.filter(i => i.variantId !== variantId));
};
const updateQuantity = (variantId: string, quantity: number) => {
if (quantity <= 0) { removeFromCart(variantId); return; }
setCart(cart.map(i => i.variantId === variantId ? { ...i, quantity } : i));
};
const subtotal = cart.reduce((s, i) => s + (i.unitPriceCents - i.discountCents) * i.quantity, 0);
const tax = cart.reduce((s, i) => s + i.taxCents * i.quantity, 0);
const discount = cart.reduce((s, i) => s + i.discountCents * i.quantity, 0);
const total = subtotal + tax;
const handleApplyDiscount = (variantId: string, discountCents: number) => {
setCart(cart.map(i => i.variantId === variantId ? { ...i, discountCents } : i));
};
const handleCustomerSearch = async (q: string) => {
setCustomerQuery(q);
if (q.trim().length < 2) { setCustomerResults([]); return; }
try {
const res = await fetch(`/api/pos/customers/search?q=${encodeURIComponent(q)}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json() as { items: typeof customer[] };
setCustomerResults(data.items ?? []);
}
} catch { setCustomerResults([]); }
};
const completeSale = async (kind: 'cash' | 'card') => {
if (!config?.session) { setError('No hay sesión abierta'); return; }
if (cart.length === 0) { setError('Carrito vacío'); return; }
setProcessing(true);
setError('');
try {
const result = await fetch('/api/pos/sales', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
idempotencyKey: generateIdempotencyKey(),
cashSessionId: config.session.id,
terminalId: config.terminal.id,
items: cart.map(i => ({ variantId: i.variantId, productId: i.productId, sku: i.sku, ean: i.ean, name: i.name, unitPriceCents: i.unitPriceCents, discountCents: i.discountCents, taxCents: i.taxCents, quantity: i.quantity })),
payments: [{ kind, amountCents: total }],
customerId: customer?.id,
}),
});
if (!result.ok) {
const err = await result.json() as { message?: string };
throw new Error((err as { message?: string }).message ?? 'Sale failed');
}
const data = await result.json() as { orderId: string; totalCents: number };
setLastSale({ orderId: data.orderId, totalCents: data.totalCents });
setCart([]);
setCustomer(null);
setTimeout(() => setLastSale(null), 5000);
} catch (err: { message?: string }) {
setError((err as { message?: string }).message ?? 'Error');
} finally {
setProcessing(false);
}
};
if (!config) {
return <div className="flex items-center justify-center min-h-screen text-gray-500">Cargando TPV</div>;
}
if (status === 'no-session') {
if (config.session?.status !== 'OPEN') {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
<h1 className="text-3xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>TPV sin vincular</h1>
<p className="text-gray-500 mb-6">Usa un código de vinculación desde la admin para activar este terminal.</p>
<div className="flex flex-col items-center justify-center min-h-screen text-center p-8">
<h1 className="text-4xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>Sesión cerrada</h1>
<p className="text-gray-500 mb-6">Abre una caja desde la administración para comenzar.</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center min-h-screen text-center">
<h1 className="text-4xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>Mercado de Vida</h1>
<p className="text-gray-500">TPV listo. Implementa la pantalla de venta en POS-007.</p>
<div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}>
{/* Left: product search */}
<div className="flex-1 flex flex-col p-4 border-r overflow-hidden">
<div className="mb-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-bold" style={{ color: 'var(--color-primary)' }}>{config.store.name}</span>
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Caja abierta</span>
</div>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Buscar producto o escanear EAN…"
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-lg focus:border-[#2D6A4F] outline-none"
autoFocus
/>
</div>
{searchResults.length > 0 && (
<div className="flex-1 overflow-y-auto space-y-2">
{searchResults.map(p => (
<button
key={p.variantId}
onClick={() => addToCart(p)}
className="w-full text-left bg-white rounded-xl p-3 shadow-sm hover:shadow-md transition-shadow flex justify-between items-center"
>
<div>
<p className="font-medium">{p.name}</p>
<p className="text-xs text-gray-400">{p.sku} {p.ean ? `· ${p.ean}` : ''}</p>
</div>
<div className="text-right">
<p className="font-bold" style={{ color: 'var(--color-primary)' }}>{formatPrice(p.priceCents)}</p>
<p className={`text-xs ${p.stock > 0 ? 'text-green-600' : 'text-red-500'}`}>{p.stock > 0 ? `${p.stock} uds` : 'Sin stock'}</p>
</div>
</button>
))}
</div>
)}
{searching && <p className="text-center text-gray-400 py-4">Buscando</p>}
{search && searchResults.length === 0 && !searching && (
<p className="text-center text-gray-400 py-4">Sin resultados para &quot;{search}&quot;</p>
)}
</div>
{/* Right: cart */}
<div className="w-[420px] flex flex-col p-4 bg-gray-50">
<div className="flex justify-between items-center mb-3">
<h2 className="text-lg font-bold" style={{ color: 'var(--color-primary)' }}>Carrito</h2>
{customer ? (
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
{customer.firstName} {customer.lastName}
</span>
) : (
<button onClick={() => setShowCustomerSearch(!showCustomerSearch)} className="text-xs text-blue-600 underline">
+ Asociar cliente
</button>
)}
</div>
{showCustomerSearch && (
<div className="mb-3 bg-white rounded-xl p-3 shadow">
<input
type="text"
value={customerQuery}
onChange={e => handleCustomerSearch(e.target.value)}
placeholder="Buscar cliente…"
className="w-full px-3 py-2 border rounded-lg text-sm mb-2"
autoFocus
/>
<div className="space-y-1 max-h-32 overflow-y-auto">
{customerResults.map(c => (
<button key={c.id} onClick={() => { setCustomer(c); setShowCustomerSearch(false); setCustomerQuery(''); setCustomerResults([]); }}
className="w-full text-left px-2 py-1 text-sm hover:bg-blue-50 rounded-lg">
{c.email} {c.firstName ? `(${c.firstName} ${c.lastName})` : ''}
</button>
))}
</div>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2">
{cart.map(item => (
<div key={item.variantId} className="bg-white rounded-xl p-3 shadow-sm">
<div className="flex justify-between items-start">
<div className="flex-1">
<p className="font-medium text-sm">{item.name}</p>
<p className="text-xs text-gray-400">{formatPrice(item.unitPriceCents - item.discountCents)}/ud</p>
</div>
<button onClick={() => removeFromCart(item.variantId)} className="text-gray-300 hover:text-red-500 ml-2"></button>
</div>
<div className="flex justify-between items-center mt-2">
<div className="flex items-center gap-2">
<button onClick={() => updateQuantity(item.variantId, item.quantity - 1)} className="w-7 h-7 rounded-lg bg-gray-100 font-bold text-sm"></button>
<span className="font-medium w-6 text-center">{item.quantity}</span>
<button onClick={() => updateQuantity(item.variantId, item.quantity + 1)} className="w-7 h-7 rounded-lg bg-gray-100 font-bold text-sm">+</button>
</div>
<div className="flex gap-2 items-center">
{item.discountCents > 0 && (
<button onClick={() => { setSelectedItem(item); setShowDiscountPanel(true); }}
className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded-full">
-{formatPrice(item.discountCents)}
</button>
)}
<button onClick={() => { setSelectedItem(item); setShowDiscountPanel(true); }}
className="text-xs text-gray-400 underline">Dto.</button>
<span className="font-bold text-sm">{formatPrice((item.unitPriceCents - item.discountCents) * item.quantity)}</span>
</div>
</div>
</div>
))}
</div>
{/* Totals */}
<div className="mt-3 space-y-1">
<div className="flex justify-between text-sm"><span>Subtotal</span><span>{formatPrice(subtotal)}</span></div>
{discount > 0 && <div className="flex justify-between text-sm text-red-500"><span>Descuentos</span><span>{formatPrice(discount)}</span></div>}
<div className="flex justify-between text-sm"><span>IVA</span><span>{formatPrice(tax)}</span></div>
<div className="flex justify-between text-lg font-bold border-t pt-2 mt-2"><span>Total</span><span style={{ color: 'var(--color-primary)' }}>{formatPrice(total)}</span></div>
</div>
{error && <p className="text-sm text-red-500 mt-2 text-center">{error}</p>}
{lastSale && (
<div className="text-sm text-green-600 mt-2 text-center bg-green-50 rounded-xl p-2">
Venta #{lastSale.orderId.slice(0, 8)} · {formatPrice(lastSale.totalCents)}
</div>
)}
{/* Payment buttons */}
<div className="mt-3 grid grid-cols-2 gap-2">
<button
onClick={() => completeSale('cash')}
disabled={processing || cart.length === 0}
className="py-3 bg-green-600 hover:bg-green-700 disabled:opacity-40 text-white font-bold rounded-xl transition-colors"
>
{processing ? '…' : '💵 Efectivo'}
</button>
<button
onClick={() => completeSale('card')}
disabled={processing || cart.length === 0}
className="py-3 bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white font-bold rounded-xl transition-colors"
>
{processing ? '…' : '💳 Tarjeta'}
</button>
</div>
</div>
{/* Discount panel overlay */}
{showDiscountPanel && selectedItem && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<DiscountPanel
unitPriceCents={selectedItem.unitPriceCents}
onApply={(dc) => { handleApplyDiscount(selectedItem.variantId, dc); setShowDiscountPanel(false); setSelectedItem(null); }}
onClose={() => { setShowDiscountPanel(false); setSelectedItem(null); }}
/>
</div>
)}
</div>
);
}