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

@@ -7081,13 +7081,15 @@
"description": "POS customer search returns no clients and cashier must be able to create a customer from the register",
"priority": "high",
"risk": "med",
"status": "pending",
"status": "done",
"created_at": "2026-08-22",
"gates": {
"reviewer": false,
"security": false,
"qa": false
}
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"completed_at": "2026-08-22T17:10:02Z"
},
{
"id": "F-182",

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[] };
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);
}
} catch { setCustomerResults([]); }
};
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">
<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="text"
type="search"
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"
placeholder="Email, nombre o teléfono…"
className="w-full rounded-lg border px-3 py-2 text-sm"
autoFocus
/>
<div className="space-y-1 max-h-32 overflow-y-auto">
<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 text-left px-2 py-1 text-sm hover:bg-blue-50 rounded-lg">
{c.email} {c.firstName ? `(${c.firstName} ${c.lastName})` : ''}
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');

View File

@@ -0,0 +1,3 @@
# F-181
Use display_name/phone schema. Create identity customer transactionally with random Argon2 secret; expose search/create in register.

View File

@@ -0,0 +1,3 @@
# F-181
TPV permite buscar por email, nombre o teléfono y crear/asociar clientes.

View File

@@ -0,0 +1,3 @@
# F-181
Search aligned to display_name/profile/identity phone and customer role. Added secure random-Argon2 customer creation endpoint and search/create register tabs; creation associates customer immediately. Runtime create 201/search 200; test customer removed. POS build/typecheck pass.

View File

@@ -0,0 +1 @@
{"feature_id":"F-181","agent":"leader","stage":"close","verdict":"APPROVED","checks":[{"item":"all gates/runtime/verify","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-181","agent":"qa","stage":"qa_gate","verdict":"APPROVED","checks":[{"item":"create 201","ok":true},{"item":"phone search 200","ok":true},{"item":"build/typecheck","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-181","agent":"reviewer","stage":"review_gate","verdict":"APPROVED","checks":[{"item":"current profile schema","ok":true},{"item":"create and associate UI","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-181","agent":"security","stage":"security_gate","verdict":"APPROVED","checks":[{"item":"staff RBAC","ok":true},{"item":"random Argon2 non-usable secret","ok":true},{"item":"transactional insert","ok":true}],"issues":[]}

View File

@@ -1,3 +1,3 @@
# F-180Decimal euro discounts
# F-181POS customer search and creation
Fixed discount input is a human EUR amount. Accept decimal comma or point and convert exactly once to integer cents. Percentage mode remains percentage.
Align customer query with current profile schema and add an authenticated cashier endpoint/form to create a customer with email, name and phone. Generated credentials are non-usable; customer can later use password reset.

View File

@@ -1,64 +1,64 @@
{
"feature_id": "F-180",
"feature_id": "F-181",
"stage": "close",
"agent": "leader",
"action": "close",
"state": "running",
"next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-22T17:07:28Z",
"updated_at": "2026-08-22T17:10:01Z",
"timeline": [
{
"ts": "2026-08-22T17:06:25Z",
"ts": "2026-08-22T17:08:03Z",
"agent": "leader",
"stage": "intake",
"state": "running",
"message": "Accept decimal euro fixed discounts"
"message": "Repair POS customer lookup and creation"
},
{
"ts": "2026-08-22T17:06:25Z",
"ts": "2026-08-22T17:08:03Z",
"agent": "architect",
"stage": "design",
"state": "running",
"message": "design"
},
{
"ts": "2026-08-22T17:06:25Z",
"ts": "2026-08-22T17:08:03Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Implement decimal EUR discounts"
"message": "Implement customer search/create"
},
{
"ts": "2026-08-22T17:07:28Z",
"ts": "2026-08-22T17:10:01Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "review"
},
{
"ts": "2026-08-22T17:07:28Z",
"ts": "2026-08-22T17:10:01Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "security"
},
{
"ts": "2026-08-22T17:07:28Z",
"ts": "2026-08-22T17:10:01Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "qa"
},
{
"ts": "2026-08-22T17:07:28Z",
"ts": "2026-08-22T17:10:01Z",
"agent": "documenter",
"stage": "document",
"state": "running",
"message": "document"
},
{
"ts": "2026-08-22T17:07:28Z",
"ts": "2026-08-22T17:10:01Z",
"agent": "leader",
"stage": "close",
"state": "running",