feat(F-181): completed feature

This commit is contained in:
chattie
2026-08-22 19:10:02 +02:00
parent ca611467ff
commit 86637f8c35
13 changed files with 175 additions and 49 deletions

View File

@@ -33,8 +33,8 @@ interface Config {
interface Customer {
id: string;
email: string;
firstName?: string;
lastName?: string;
displayName?: string | null;
phone?: string | null;
}
export default function RegisterPage() {
@@ -57,6 +57,10 @@ export default function RegisterPage() {
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 [lastSale, setLastSale] = useState<{ orderId: string; totalCents: number } | null>(null);
const [error, setError] = useState('');
@@ -196,13 +200,35 @@ export default function RegisterPage() {
const handleCustomerSearch = async (q: string) => {
setCustomerQuery(q);
if (q.trim().length < 2) { setCustomerResults([]); return; }
setCustomerError('');
try {
const res = await fetch(`/api/pos/customers/search?q=${encodeURIComponent(q)}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json() as { items: Customer[] };
setCustomerResults(data.items ?? []);
}
} catch { setCustomerResults([]); }
const data = await posApi.searchCustomers(q) 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 completeSale = async (kind: 'cash' | 'card') => {
@@ -376,7 +402,7 @@ export default function RegisterPage() {
<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}
{customer.displayName || customer.email}
</span>
) : (
<button onClick={() => setShowCustomerSearch(!showCustomerSearch)} className="text-xs text-blue-600 underline">
@@ -386,23 +412,41 @@ export default function RegisterPage() {
</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 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'); setCustomerError(''); }} className={`flex-1 rounded-md py-1.5 ${customerMode === 'search' ? 'bg-white text-[#2D6A4F] shadow-sm' : 'text-gray-500'}`}>Buscar</button>
<button type="button" onClick={() => { setCustomerMode('create'); setCustomerError(''); }} className={`flex-1 rounded-md py-1.5 ${customerMode === 'create' ? 'bg-white text-[#2D6A4F] shadow-sm' : 'text-gray-500'}`}>Crear cliente</button>
</div>
{customerMode === 'search' ? (
<>
<input
type="search"
value={customerQuery}
onChange={e => handleCustomerSearch(e.target.value)}
placeholder="Email, nombre o teléfono…"
className="w-full rounded-lg border px-3 py-2 text-sm"
autoFocus
/>
<div className="max-h-36 space-y-1 overflow-y-auto">
{customerResults.map(c => (
<button key={c.id} onClick={() => { setCustomer(c); setShowCustomerSearch(false); setCustomerQuery(''); setCustomerResults([]); }}
className="w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-blue-50">
<span className="block font-medium">{c.displayName || c.email}</span>
{c.displayName && <span className="block text-xs text-gray-500">{c.email}{c.phone ? ` · ${c.phone}` : ''}</span>}
</button>
))}
{customerQuery.trim().length >= 2 && customerResults.length === 0 && !customerError && <p className="py-2 text-center text-xs text-gray-400">Sin coincidencias</p>}
</div>
</>
) : (
<form onSubmit={createCustomer} className="space-y-2">
<input type="email" required value={newCustomer.email} onChange={e => setNewCustomer({ ...newCustomer, email: e.target.value })} placeholder="Email *" className="w-full rounded-lg border px-3 py-2 text-sm" autoFocus />
<input value={newCustomer.displayName} onChange={e => setNewCustomer({ ...newCustomer, displayName: e.target.value })} placeholder="Nombre" className="w-full rounded-lg border px-3 py-2 text-sm" />
<input type="tel" value={newCustomer.phone} onChange={e => setNewCustomer({ ...newCustomer, phone: e.target.value })} placeholder="Teléfono" className="w-full rounded-lg border px-3 py-2 text-sm" />
<button disabled={creatingCustomer} className="w-full rounded-lg bg-[#2D6A4F] py-2 text-sm font-semibold text-white disabled:opacity-50">{creatingCustomer ? 'Creando…' : 'Crear y asociar'}</button>
</form>
)}
{customerError && <p className="text-xs text-red-600">{customerError}</p>}
</div>
)}

View File

@@ -48,6 +48,11 @@ export const posApi = {
productByEan: (ean: string) => apiFetch(`/pos/products/by-ean/${encodeURIComponent(ean)}`),
/** Get product by SKU. */
productBySku: (sku: string) => apiFetch(`/pos/products/by-sku/${encodeURIComponent(sku)}`),
/** Search identity customers for sale association. */
searchCustomers: (q: string) => apiFetch(`/pos/customers/search?q=${encodeURIComponent(q)}`),
/** Create an identity customer from the register. */
createCustomer: <T>(data: { email: string; displayName?: string; phone?: string }) =>
apiFetch<T>('/pos/customers', { method: 'POST', body: JSON.stringify(data) }),
};
export const authApi = {