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 = {

View File

@@ -826,17 +826,79 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const { q, limit = 10 } = request.query as { q?: string; limit?: number };
if (!q || q.trim().length < 2) return reply.send({ items: [] });
const result = await pool.query(
`SELECT u.id, u.email, p.first_name AS "firstName", p.last_name AS "lastName", p.phone
`SELECT u.id, u.email, p.display_name AS "displayName", COALESCE(p.phone, u.phone) AS phone
FROM identity_users u
LEFT JOIN users_profiles p ON p.user_id = u.id
WHERE u.email ILIKE $1 OR p.first_name ILIKE $1 OR p.last_name ILIKE $1 OR p.phone ILIKE $1
ORDER BY p.last_name LIMIT $2`,
WHERE u.role = 'customer'
AND (u.email ILIKE $1 OR p.display_name ILIKE $1 OR p.phone ILIKE $1 OR u.phone ILIKE $1)
ORDER BY p.display_name NULLS LAST, u.email LIMIT $2`,
[`%${q.trim()}%`, limit],
);
return reply.send({ items: result.rows });
},
);
app.post(
'/pos/customers',
{
schema: {
tags: ['POS Terminal'],
summary: 'Create a customer from POS',
body: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
displayName: { type: 'string', maxLength: 160 },
phone: { type: 'string', maxLength: 40 },
},
},
response: { 401: errorSchema, 409: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const body = parseJson(
z.object({
email: z.string().trim().email().transform((value) => value.toLowerCase()),
displayName: z.string().trim().min(1).max(160).optional(),
phone: z.string().trim().min(1).max(40).optional(),
}),
request.body ?? {},
);
const existing = await pool.query('SELECT id FROM identity_users WHERE lower(email) = $1', [body.email]);
if (existing.rows[0]) throw new AppError(409, 'CUSTOMER_EXISTS', 'Ya existe un cliente con ese email');
const { randomBytes } = await import('node:crypto');
const { Argon2PasswordHasher } = await import('../../identity/infrastructure/argon2-password-hasher.js');
const passwordHash = await new Argon2PasswordHasher().hash(randomBytes(32).toString('hex'));
const client = await pool.connect();
try {
await client.query('BEGIN');
const created = await client.query<{ id: string; email: string }>(
`INSERT INTO identity_users (email, password_hash, role, phone)
VALUES ($1, $2, 'customer', $3) RETURNING id, email`,
[body.email, passwordHash, body.phone ?? null],
);
const customer = created.rows[0];
if (!customer) throw new AppError(500, 'CUSTOMER_CREATE_FAILED', 'No se pudo crear el cliente');
await client.query(
`INSERT INTO users_profiles (user_id, display_name, phone)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE SET display_name = EXCLUDED.display_name, phone = EXCLUDED.phone`,
[customer.id, body.displayName ?? null, body.phone ?? null],
);
await client.query('COMMIT');
return reply.code(201).send({ ...customer, displayName: body.displayName ?? null, phone: body.phone ?? null });
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
},
);
app.get<{ Params: { id: string } }>(
'/pos/customers/:id',
{
@@ -852,10 +914,10 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { id } = request.params;
const result = await pool.query(
`SELECT u.id, u.email, p.first_name AS "firstName", p.last_name AS "lastName", p.phone
`SELECT u.id, u.email, p.display_name AS "displayName", COALESCE(p.phone, u.phone) AS phone
FROM identity_users u
LEFT JOIN users_profiles p ON p.user_id = u.id
WHERE u.id = $1 LIMIT 1`,
WHERE u.id = $1 AND u.role = 'customer' LIMIT 1`,
[id],
);
if (!result.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Customer not found');