From cf3c906ed23dabea77b86c8705ffda25cc35bcdb Mon Sep 17 00:00:00 2001 From: Deploy Date: Wed, 26 Aug 2026 07:43:13 +0200 Subject: [PATCH] fix(pos): redesign quick products admin search and save flow --- project/VERSION | 2 +- project/apps/admin/package-lock.json | 4 +- project/apps/admin/package.json | 2 +- .../admin/src/app/(dashboard)/pos/page.tsx | 267 ++++++++++++++++-- project/apps/pos/package-lock.json | 4 +- project/apps/pos/package.json | 2 +- project/frontend/package-lock.json | 4 +- project/frontend/package.json | 2 +- project/package-lock.json | 4 +- project/package.json | 2 +- project/src/modules/pos/api/pos.routes.ts | 56 +++- project/storefront/package-lock.json | 4 +- project/storefront/package.json | 2 +- .../POS-RECEIPT-QUICK-FIXES/implementer.md | 40 +++ work/runtime-status.json | 38 +-- 15 files changed, 358 insertions(+), 75 deletions(-) create mode 100644 work/artifacts/POS-RECEIPT-QUICK-FIXES/implementer.md diff --git a/project/VERSION b/project/VERSION index 1866a36..13dead7 100644 --- a/project/VERSION +++ b/project/VERSION @@ -1 +1 @@ -0.2.9 +0.2.10 diff --git a/project/apps/admin/package-lock.json b/project/apps/admin/package-lock.json index 149d78d..0e1d3cc 100644 --- a/project/apps/admin/package-lock.json +++ b/project/apps/admin/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mercadodevida/admin", - "version": "0.2.9", + "version": "0.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mercadodevida/admin", - "version": "0.2.9", + "version": "0.2.10", "dependencies": { "@lexical/history": "^0.49.0", "@lexical/html": "^0.49.0", diff --git a/project/apps/admin/package.json b/project/apps/admin/package.json index eabc2e9..7b6c371 100644 --- a/project/apps/admin/package.json +++ b/project/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@mercadodevida/admin", - "version": "0.2.9", + "version": "0.2.10", "private": true, "scripts": { "dev": "next dev --port 3001", diff --git a/project/apps/admin/src/app/(dashboard)/pos/page.tsx b/project/apps/admin/src/app/(dashboard)/pos/page.tsx index 2cbed66..26ec376 100644 --- a/project/apps/admin/src/app/(dashboard)/pos/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/pos/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@/lib/api-client'; interface PosStore { @@ -28,6 +28,8 @@ interface CatalogOption { variantId: string; name: string; sku: string; + ean?: string | null; + priceCents?: number; } interface PaymentMethod { id: string; @@ -80,10 +82,15 @@ const emptyReceipt: ReceiptSettings = { returnPolicy: 'Se admiten devoluciones durante 15 días con el ticket de compra.', }; +const QUICK_PRODUCTS_MAX = 8; + export default function PosAdminPage() { const [stores, setStores] = useState([]); const [terminals, setTerminals] = useState([]); const [catalogOptions, setCatalogOptions] = useState([]); + const [quickSearch, setQuickSearch] = useState(''); + const [quickSearchResults, setQuickSearchResults] = useState([]); + const [quickSearchLoading, setQuickSearchLoading] = useState(false); const [storeId, setStoreId] = useState(''); const [terminalName, setTerminalName] = useState(''); const [loading, setLoading] = useState(true); @@ -95,7 +102,7 @@ export default function PosAdminPage() { const [touchEnabled, setTouchEnabled] = useState(true); const [discountsEnabled, setDiscountsEnabled] = useState(true); const [quickSlots, setQuickSlots] = useState>( - Array.from({ length: 10 }, () => null), + Array.from({ length: QUICK_PRODUCTS_MAX }, () => null), ); const [savingTouch, setSavingTouch] = useState(false); const [touchMessage, setTouchMessage] = useState(''); @@ -121,6 +128,33 @@ export default function PosAdminPage() { const [savingReceipt, setSavingReceipt] = useState(false); const [receiptMessage, setReceiptMessage] = useState(''); + const mergeCatalogOptions = useCallback((items: CatalogOption[]) => { + setCatalogOptions((current) => { + const merged = new Map(current.map((item) => [item.variantId, item])); + items.forEach((item) => merged.set(item.variantId, item)); + return Array.from(merged.values()); + }); + }, []); + + const catalogById = useMemo( + () => new Map(catalogOptions.map((item) => [item.variantId, item])), + [catalogOptions], + ); + + const fetchCatalogOptions = useCallback( + async (params: { q?: string; limit?: number; variantIds?: string[] } = {}) => { + const qs = new URLSearchParams(); + if (params.q?.trim()) qs.set('q', params.q.trim()); + if (params.limit) qs.set('limit', String(params.limit)); + if (params.variantIds?.length) qs.set('variantIds', params.variantIds.join(',')); + const suffix = qs.toString(); + return api.get<{ items: CatalogOption[] }>( + `/api/pos/admin/catalog-products${suffix ? `?${suffix}` : ''}`, + ); + }, + [], + ); + const loadStoreConfiguration = useCallback(async (selectedStoreId: string) => { if (!selectedStoreId) return; try { @@ -143,15 +177,13 @@ export default function PosAdminPage() { setLoading(true); setError(''); try { - const [storeData, terminalData, catalogData, userData] = await Promise.all([ + const [storeData, terminalData, 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); @@ -195,16 +227,83 @@ export default function PosAdminPage() { }; const openTouchConfig = (terminal: PosTerminal) => { + const configured = terminal.settings?.quickProductVariantIds ?? []; + const selectedIds = configured.filter((value): value is string => typeof value === 'string'); 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)); + setQuickSlots(Array.from({ length: QUICK_PRODUCTS_MAX }, (_, slot) => configured[slot] ?? null)); + setQuickSearch(''); + setQuickSearchResults([]); setTouchMessage(''); setSelfpayMode(terminal.settings?.selfpayMode ?? false); setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false); setClosePinValue(terminal.settings?.closeSessionPin ?? ''); setSecurityMessage(''); + if (selectedIds.length > 0) { + void fetchCatalogOptions({ variantIds: selectedIds }) + .then((data) => mergeCatalogOptions(data.items)) + .catch((err) => { + setTouchMessage(err instanceof Error ? err.message : 'No se pudo cargar la selección actual'); + }); + } + }; + + useEffect(() => { + if (!configuring) return; + let cancelled = false; + const timer = setTimeout(() => { + void (async () => { + setQuickSearchLoading(true); + try { + const data = await fetchCatalogOptions({ q: quickSearch, limit: 12 }); + if (cancelled) return; + mergeCatalogOptions(data.items); + setQuickSearchResults(data.items); + } catch (err) { + if (!cancelled) { + setTouchMessage(err instanceof Error ? err.message : 'No se pudo buscar productos'); + } + } finally { + if (!cancelled) setQuickSearchLoading(false); + } + })(); + }, 250); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [configuring, fetchCatalogOptions, mergeCatalogOptions, quickSearch]); + + const addQuickProduct = (product: CatalogOption) => { + if (quickSlots.includes(product.variantId)) { + setTouchMessage('Ese producto ya está en accesos rápidos'); + return; + } + const emptySlot = quickSlots.findIndex((value) => value === null); + if (emptySlot === -1) { + setTouchMessage(`Solo puedes guardar ${QUICK_PRODUCTS_MAX} productos rápidos`); + return; + } + setQuickSlots((current) => current.map((value, index) => (index === emptySlot ? product.variantId : value))); + mergeCatalogOptions([product]); + setTouchMessage(''); + }; + + const removeQuickProduct = (slot: number) => { + setQuickSlots((current) => current.map((value, index) => (index === slot ? null : value))); + setTouchMessage(''); + }; + + const moveQuickProduct = (slot: number, direction: -1 | 1) => { + const target = slot + direction; + if (target < 0 || target >= QUICK_PRODUCTS_MAX) return; + setQuickSlots((current) => { + const next = [...current]; + [next[slot], next[target]] = [next[target], next[slot]]; + return next; + }); + setTouchMessage(''); }; const saveTouchConfig = async () => { @@ -617,30 +716,136 @@ export default function PosAdminPage() {

Desactiva descuentos para terminales de autopago.

-
- {quickSlots.map((variantId, slot) => ( - - ))} +
+
+
+

+ Productos rápidos ({quickSlots.filter((value) => value !== null).length}/{QUICK_PRODUCTS_MAX}) +

+

+ Busca por nombre, SKU o EAN y añade productos a una lista de hasta ocho accesos rápidos. +

+
+ +
+ + + +
+ {quickSearchLoading ? ( +

Buscando productos…

+ ) : quickSearchResults.length > 0 ? ( + quickSearchResults.map((product) => { + const isSelected = quickSlots.includes(product.variantId); + const isFull = !isSelected && quickSlots.every((value) => value !== null); + return ( +
+
+

{product.name}

+

+ {product.sku} + {product.ean ? ` · EAN ${product.ean}` : ''} +

+
+ +
+ ); + }) + ) : ( +

No se encontraron productos para esa búsqueda.

+ )} +
+ +
+ {quickSlots.map((variantId, slot) => { + const product = variantId ? catalogById.get(variantId) : null; + return ( +
+
+

+ Botón {slot + 1} +

+ {variantId ? ( + <> +

+ {product?.name ?? 'Producto configurado previamente'} +

+

+ {product?.sku ?? variantId} + {product?.ean ? ` · EAN ${product.ean}` : ''} +

+ + ) : ( +

Sin asignar

+ )} +
+
+ + + +
+
+ ); + })} +
+ + {touchMessage && ( +

+ {touchMessage} +

+ )}
{/* F-203: cash close security settings */}
diff --git a/project/apps/pos/package-lock.json b/project/apps/pos/package-lock.json index 3928b3b..6519f86 100644 --- a/project/apps/pos/package-lock.json +++ b/project/apps/pos/package-lock.json @@ -1,12 +1,12 @@ { "name": "mercadodevida-pos", - "version": "0.2.9", + "version": "0.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mercadodevida-pos", - "version": "0.2.9", + "version": "0.2.10", "dependencies": { "next": "^16.3.1", "react": "^19.2.8", diff --git a/project/apps/pos/package.json b/project/apps/pos/package.json index fd3971e..d13444c 100644 --- a/project/apps/pos/package.json +++ b/project/apps/pos/package.json @@ -1,6 +1,6 @@ { "name": "mercadodevida-pos", - "version": "0.2.9", + "version": "0.2.10", "private": true, "scripts": { "dev": "next dev --port 3002", diff --git a/project/frontend/package-lock.json b/project/frontend/package-lock.json index 25e1a2b..f16c9c9 100644 --- a/project/frontend/package-lock.json +++ b/project/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.2.9", + "version": "0.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.2.9", + "version": "0.2.10", "dependencies": { "next": "16.3.1", "react": "19.2.8", diff --git a/project/frontend/package.json b/project/frontend/package.json index adac0df..ad1cea2 100644 --- a/project/frontend/package.json +++ b/project/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.2.9", + "version": "0.2.10", "private": true, "scripts": { "dev": "next dev", diff --git a/project/package-lock.json b/project/package-lock.json index f840574..17bda95 100644 --- a/project/package-lock.json +++ b/project/package-lock.json @@ -1,12 +1,12 @@ { "name": "mercadodevida-backend", - "version": "0.2.9", + "version": "0.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mercadodevida-backend", - "version": "0.2.9", + "version": "0.2.10", "dependencies": { "@fastify/cookie": "^11.1.2", "@fastify/cors": "^11.3.0", diff --git a/project/package.json b/project/package.json index 7a17ed1..5375f63 100644 --- a/project/package.json +++ b/project/package.json @@ -1,6 +1,6 @@ { "name": "mercadodevida-backend", - "version": "0.2.9", + "version": "0.2.10", "private": true, "type": "module", "description": "mercadodevida vNext backend - modular monolith skeleton", diff --git a/project/src/modules/pos/api/pos.routes.ts b/project/src/modules/pos/api/pos.routes.ts index 4f3a278..900a437 100644 --- a/project/src/modules/pos/api/pos.routes.ts +++ b/project/src/modules/pos/api/pos.routes.ts @@ -261,22 +261,60 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps { schema: { tags: ['POS Admin'], - summary: 'List products available for TPV touch configuration', + summary: 'Search products available for TPV touch configuration', + querystring: { + type: 'object', + properties: { + q: { type: 'string', minLength: 1, maxLength: 120 }, + limit: { type: 'integer', minimum: 1, maximum: 40, default: 12 }, + variantIds: { type: 'string' }, + }, + }, response: { 401: errorSchema, 403: errorSchema }, } as FastifySchema, }, async (request, reply) => { const user = await authenticate(request); requireRole(user, 'admin'); - const result = await pool.query( - `SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean, - COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents" - FROM catalog_product_variants v - JOIN catalog_products p ON p.id = v.product_id - LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR' - WHERE p.state = 'active' - ORDER BY p.name, v.sku`, + const query = parseJson( + z.object({ + q: z.string().trim().min(1).max(120).optional(), + limit: z.coerce.number().int().min(1).max(40).default(12), + variantIds: z.string().optional(), + }), + request.query ?? {}, ); + const variantIds = (query.variantIds ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + if (variantIds.some((value) => !storeIdSchema.safeParse(value).success)) { + throw new AppError(400, 'INVALID_VARIANT_ID', 'Algún producto rápido no es válido'); + } + + const result = variantIds.length > 0 + ? await pool.query( + `SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean, + COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents" + FROM catalog_product_variants v + JOIN catalog_products p ON p.id = v.product_id + LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR' + WHERE p.state = 'active' AND v.id = ANY($1::uuid[]) + ORDER BY p.name, v.sku`, + [variantIds], + ) + : await pool.query( + `SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean, + COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents" + FROM catalog_product_variants v + JOIN catalog_products p ON p.id = v.product_id + LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR' + WHERE p.state = 'active' + AND ($1::text IS NULL OR p.name ILIKE $1 OR v.sku ILIKE $1 OR COALESCE(v.ean, '') ILIKE $1) + ORDER BY p.name, v.sku + LIMIT $2`, + [query.q ? `%${query.q}%` : null, query.limit], + ); return reply.send({ items: result.rows }); }, ); diff --git a/project/storefront/package-lock.json b/project/storefront/package-lock.json index a30c0c1..b564757 100644 --- a/project/storefront/package-lock.json +++ b/project/storefront/package-lock.json @@ -1,12 +1,12 @@ { "name": "mercadodevida-storefront", - "version": "0.2.9", + "version": "0.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mercadodevida-storefront", - "version": "0.2.9", + "version": "0.2.10", "dependencies": { "@tailwindcss/postcss": "^4.1.17", "next": "^16.0.5", diff --git a/project/storefront/package.json b/project/storefront/package.json index f48e038..edfb494 100644 --- a/project/storefront/package.json +++ b/project/storefront/package.json @@ -1,6 +1,6 @@ { "name": "mercadodevida-storefront", - "version": "0.2.9", + "version": "0.2.10", "private": true, "type": "module", "description": "mercadodevida customer storefront shell", diff --git a/work/artifacts/POS-RECEIPT-QUICK-FIXES/implementer.md b/work/artifacts/POS-RECEIPT-QUICK-FIXES/implementer.md new file mode 100644 index 0000000..3407190 --- /dev/null +++ b/work/artifacts/POS-RECEIPT-QUICK-FIXES/implementer.md @@ -0,0 +1,40 @@ +# Implementer evidence — POS-RECEIPT-QUICK-FIXES + +## Cambios realizados + +### 1) Productos rápidos: nuevo flujo con buscador + lista de hasta 8 +- Reemplacé los `