From b860e8d1df079ddd9e2d0417f08f071602375cfd Mon Sep 17 00:00:00 2001 From: chattie Date: Sat, 22 Aug 2026 19:25:12 +0200 Subject: [PATCH] feat(F-182): completed feature --- backlog/features.json | 12 +- .../admin/src/app/(dashboard)/pos/page.tsx | 73 ++++++++- project/apps/pos/src/app/(terminal)/page.tsx | 103 +++++++++++- project/apps/pos/src/lib/api-client.ts | 2 + project/src/modules/pos/api/pos.routes.ts | 153 ++++++++++++++++++ work/artifacts/F-182/architect.md | 3 + work/artifacts/F-182/documenter.md | 3 + work/artifacts/F-182/implementer.md | 3 + work/artifacts/F-182/leader-close.json | 1 + work/artifacts/F-182/qa.json | 1 + work/artifacts/F-182/reviewer.json | 1 + work/artifacts/F-182/security.json | 1 + work/current.md | 4 +- work/runtime-status.json | 26 +-- 14 files changed, 361 insertions(+), 25 deletions(-) create mode 100644 work/artifacts/F-182/architect.md create mode 100644 work/artifacts/F-182/documenter.md create mode 100644 work/artifacts/F-182/implementer.md create mode 100644 work/artifacts/F-182/leader-close.json create mode 100644 work/artifacts/F-182/qa.json create mode 100644 work/artifacts/F-182/reviewer.json create mode 100644 work/artifacts/F-182/security.json diff --git a/backlog/features.json b/backlog/features.json index 333f4c7..06d1149 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -7108,13 +7108,15 @@ "description": "Build large touch buttons to navigate category to subcategory to product, plus four persistent easy-touch quick product slots at bottom", "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:25:12Z" }, { "id": "F-183", diff --git a/project/apps/admin/src/app/(dashboard)/pos/page.tsx b/project/apps/admin/src/app/(dashboard)/pos/page.tsx index 52b91b1..b15621a 100644 --- a/project/apps/admin/src/app/(dashboard)/pos/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/pos/page.tsx @@ -12,7 +12,9 @@ interface PosTerminal { boundAt: string | null; status: string; lastSeenAt: string | null; + settings?: { touchNavigationEnabled?: boolean; quickProductVariantIds?: Array }; } +interface CatalogOption { variantId: string; productId: string; name: string; sku: string; priceCents: number } export default function PosAdminPage() { const [stores, setStores] = useState([]); @@ -24,17 +26,25 @@ export default function PosAdminPage() { const [error, setError] = useState(''); const [newCode, setNewCode] = useState(''); const [copied, setCopied] = useState(''); + const [catalogOptions, setCatalogOptions] = useState([]); + const [configuring, setConfiguring] = useState(null); + const [touchEnabled, setTouchEnabled] = useState(true); + const [quickSlots, setQuickSlots] = useState>([null, null, null, null]); + const [savingTouch, setSavingTouch] = useState(false); + const [touchMessage, setTouchMessage] = useState(''); const load = async () => { setLoading(true); setError(''); try { - const [storeData, terminalData] = await Promise.all([ + const [storeData, terminalData, catalogData] = 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'), ]); setStores(storeData.stores); setTerminals(terminalData.terminals); + setCatalogOptions(catalogData.items); setStoreId((current) => current || storeData.stores.find((store) => store.active)?.id || ''); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo cargar la configuración TPV'); @@ -62,6 +72,32 @@ export default function PosAdminPage() { } }; + const openTouchConfig = (terminal: PosTerminal) => { + setConfiguring(terminal); + setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false); + const configured = terminal.settings?.quickProductVariantIds ?? []; + setQuickSlots([0, 1, 2, 3].map((slot) => configured[slot] ?? null)); + setTouchMessage(''); + }; + + const saveTouchConfig = async () => { + if (!configuring) return; + setSavingTouch(true); + setTouchMessage(''); + try { + await api.patch(`/api/pos/admin/terminals/${configuring.id}/touch-config`, { + touchNavigationEnabled: touchEnabled, + quickProductVariantIds: quickSlots, + }); + setTouchMessage('Configuración táctil guardada'); + await load(); + } catch (err) { + setTouchMessage(err instanceof Error ? err.message : 'No se pudo guardar'); + } finally { + setSavingTouch(false); + } + }; + const copyCode = async (code: string) => { await navigator.clipboard.writeText(code); setCopied(code); @@ -112,17 +148,48 @@ export default function PosAdminPage() { {loading ?

Cargando…

: terminals.length === 0 ?

No hay terminales.

: (
- + {terminals.map((terminal) => { const store = stores.find((item) => item.id === terminal.storeId); - return ; + return ; })}
NombreTiendaCódigoEstadoVinculado
NombreTiendaCódigoEstadoVinculadoInterfaz
{terminal.name}{store?.name ?? terminal.storeId}{terminal.status}{terminal.boundAt ? 'Sí' : 'No'}
{terminal.name}{store?.name ?? terminal.storeId}{terminal.status}{terminal.boundAt ? 'Sí' : 'No'}
)} + + {configuring && ( +
+
+
+

Interfaz táctil · {configuring.name}

+

Las categorías y subcategorías se administran en Categorías. Aquí activas su navegación en este terminal y eliges cuatro productos rápidos.

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

{touchMessage}

} +
+
+ )} ); } diff --git a/project/apps/pos/src/app/(terminal)/page.tsx b/project/apps/pos/src/app/(terminal)/page.tsx index 355eac7..6d512a8 100644 --- a/project/apps/pos/src/app/(terminal)/page.tsx +++ b/project/apps/pos/src/app/(terminal)/page.tsx @@ -20,12 +20,29 @@ interface CartItem { interface SearchResult { variantId: string; productId: string; name: string; sku: string; ean: string | null; - stock: number; priceCents: number; category: string | null; brand: string | null; + stock: number; priceCents: number; category?: string | null; brand?: string | null; + categoryId?: string | null; +} + +interface TouchCategory { + id: string; + parentId: string | null; + name: string; + emoji: string | null; + bgColor: string | null; + textColor: string | null; +} + +interface TouchCatalog { + enabled: boolean; + categories: TouchCategory[]; + products: SearchResult[]; + quickProducts: Array; } interface Config { session: { id: string; storeId: string; status: string } | null; - terminal: { id: string; name: string }; + terminal: { id: string; name: string; settings?: Record }; store: { id: string; name: string }; paymentMethods: { id: string; code: string; label: string; kind: string }[]; } @@ -51,6 +68,8 @@ export default function RegisterPage() { const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); const [searchError, setSearchError] = useState(''); + const [touchCatalog, setTouchCatalog] = useState(null); + const [categoryPath, setCategoryPath] = useState([]); const [selectedItem, setSelectedItem] = useState(null); const [showDiscountPanel, setShowDiscountPanel] = useState(false); const [customer, setCustomer] = useState(null); @@ -82,6 +101,16 @@ export default function RegisterPage() { void loadConfig(); }, [loadConfig]); + useEffect(() => { + if (!config?.session || config.session.status !== 'OPEN') return; + void posApi.touchCatalog() + .then((catalog) => { + setTouchCatalog(catalog); + setCategoryPath([]); + }) + .catch(() => setTouchCatalog(null)); + }, [config?.session?.id, config?.session?.status]); + const bindTerminal = async (event: React.FormEvent) => { event.preventDefault(); setBinding(true); @@ -266,6 +295,14 @@ export default function RegisterPage() { } }; + const currentCategory = categoryPath[categoryPath.length - 1] ?? null; + const visibleCategories = (touchCatalog?.categories ?? []).filter( + (category) => category.parentId === (currentCategory?.id ?? null), + ); + const visibleTouchProducts = currentCategory + ? (touchCatalog?.products ?? []).filter((product) => product.categoryId === currentCategory.id) + : []; + if (!config && needsBinding) { return (
@@ -368,6 +405,55 @@ export default function RegisterPage() { />
+ {!search.trim() && touchCatalog?.enabled && ( +
+
+ +
+ + {categoryPath.map((category, index) => ( + + ))} +
+
+
+ {visibleCategories.map((category) => ( + + ))} + {visibleTouchProducts.map((product) => ( + + ))} +
+ {currentCategory && visibleCategories.length === 0 && visibleTouchProducts.length === 0 && ( +

No hay productos activos en esta categoría.

+ )} +
+ )} + {searchResults.length > 0 && (
{searchResults.map(p => ( @@ -394,6 +480,19 @@ export default function RegisterPage() { {search && searchResults.length === 0 && !searching && !searchError && (

Sin resultados para "{search}"

)} + + {!search.trim() && touchCatalog?.enabled && ( +
+ {touchCatalog.quickProducts.map((product, slot) => product ? ( + + ) : ( +
Rápido {slot + 1}
+ ))} +
+ )}
{/* Right: cart */} diff --git a/project/apps/pos/src/lib/api-client.ts b/project/apps/pos/src/lib/api-client.ts index e888693..1550cc4 100644 --- a/project/apps/pos/src/lib/api-client.ts +++ b/project/apps/pos/src/lib/api-client.ts @@ -41,6 +41,8 @@ export const posApi = { method: 'POST', body: JSON.stringify({ openingCashCents }), }), + /** Load touch category navigation and four terminal quick products. */ + touchCatalog: () => apiFetch('/pos/catalog/touch'), /** List products by query. */ searchProducts: (q: string, storeId?: string, limit = 20) => apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`), diff --git a/project/src/modules/pos/api/pos.routes.ts b/project/src/modules/pos/api/pos.routes.ts index 6160787..a8953f7 100644 --- a/project/src/modules/pos/api/pos.routes.ts +++ b/project/src/modules/pos/api/pos.routes.ts @@ -197,6 +197,94 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps }, ); + app.get( + '/pos/admin/catalog-products', + { + schema: { + tags: ['POS Admin'], + summary: 'List products available for TPV touch configuration', + 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`, + ); + return reply.send({ items: result.rows }); + }, + ); + + app.patch<{ Params: { id: string } }>( + '/pos/admin/terminals/:id/touch-config', + { + schema: { + tags: ['POS Admin'], + summary: 'Configure terminal touch navigation and quick products', + params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } }, + body: { + type: 'object', + required: ['touchNavigationEnabled', 'quickProductVariantIds'], + properties: { + touchNavigationEnabled: { type: 'boolean' }, + quickProductVariantIds: { + type: 'array', minItems: 4, maxItems: 4, + items: { type: ['string', 'null'], format: 'uuid' }, + }, + }, + }, + response: { 401: errorSchema, 403: errorSchema, 404: errorSchema }, + } as FastifySchema, + }, + async (request, reply) => { + const user = await authenticate(request); + requireRole(user, 'admin'); + const { id } = parseJson(idParamSchema, request.params); + const body = parseJson( + z.object({ + touchNavigationEnabled: z.boolean(), + quickProductVariantIds: z.array(z.string().uuid().nullable()).length(4), + }), + request.body ?? {}, + ); + const selected = body.quickProductVariantIds.filter((value): value is string => value !== null); + if (new Set(selected).size !== selected.length) { + throw new AppError(400, 'DUPLICATE_QUICK_PRODUCT', 'Cada acceso rápido debe usar un producto diferente'); + } + if (selected.length > 0) { + const valid = await pool.query<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM catalog_product_variants v JOIN catalog_products p ON p.id = v.product_id + WHERE v.id = ANY($1::uuid[]) AND p.state = 'active'`, + [selected], + ); + if (Number(valid.rows[0]?.count ?? 0) !== selected.length) { + throw new AppError(400, 'INVALID_QUICK_PRODUCT', 'Algún producto rápido no está activo'); + } + } + const result = await pool.query( + `UPDATE pos_terminals + SET interface_mode = CASE WHEN $2 THEN 'touch' ELSE 'auto' END, + settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object( + 'touchNavigationEnabled', $2::boolean, + 'quickProductVariantIds', $3::jsonb + ), + updated_at = now() + WHERE id = $1 RETURNING *`, + [id, body.touchNavigationEnabled, JSON.stringify(body.quickProductVariantIds)], + ); + if (!result.rows[0]) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found'); + return reply.send({ ok: true }); + }, + ); + app.get<{ Params: { id: string } }>( '/pos/admin/terminals/:id', { @@ -424,6 +512,71 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps }, ); + app.get( + '/pos/catalog/touch', + { + schema: { + tags: ['POS Terminal'], + summary: 'Get category navigation and quick products for the bound terminal', + headers: { + type: 'object', required: ['x-terminal-id'], + properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } }, + }, + response: { 401: errorSchema, 404: errorSchema }, + } as FastifySchema, + }, + async (request, reply) => { + const user = await authenticate(request); + requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray); + const terminalId = request.headers['x-terminal-id'] as string | undefined; + if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required'); + const terminalResult = await pool.query<{ store_id: string; settings: Record }>( + `SELECT store_id, settings FROM pos_terminals WHERE id = $1 AND status = 'active'`, + [terminalId], + ); + const terminal = terminalResult.rows[0]; + if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found'); + const [categoryResult, productResult] = await Promise.all([ + pool.query( + `SELECT id, parent_id AS "parentId", name, emoji, bg_color AS "bgColor", text_color AS "textColor" + FROM categories_categories ORDER BY name`, + ), + pool.query( + `SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean, + pc.category_id AS "categoryId", COALESCE(stock.quantity, 0) AS stock, + 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 catalog_product_categories pc ON pc.product_id = p.id + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(available), 0)::int AS quantity + FROM inventory_stock WHERE variant_id = v.id AND store_id = $1 + ) stock ON true + 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`, + [terminal.store_id], + ), + ]); + const configured = Array.isArray(terminal.settings?.quickProductVariantIds) + ? terminal.settings.quickProductVariantIds + : [null, null, null, null]; + const uniqueProducts = new Map>(); + for (const row of productResult.rows as Array>) { + if (!uniqueProducts.has(String(row.variantId))) uniqueProducts.set(String(row.variantId), row); + } + return reply.send({ + enabled: terminal.settings?.touchNavigationEnabled !== false, + categories: categoryResult.rows, + products: productResult.rows, + quickProducts: [0, 1, 2, 3].map((slot) => { + const variantId = configured[slot]; + return typeof variantId === 'string' ? uniqueProducts.get(variantId) ?? null : null; + }), + }); + }, + ); + // ── POS-005: Product search ─────────────────────────────────────────────── app.get( diff --git a/work/artifacts/F-182/architect.md b/work/artifacts/F-182/architect.md new file mode 100644 index 0000000..19ae0f5 --- /dev/null +++ b/work/artifacts/F-182/architect.md @@ -0,0 +1,3 @@ +# F-182 + +Persist touch config in pos_terminals.settings. Category hierarchy remains sourced from Admin Categories/product assignments. Terminal endpoint returns touch catalogue and hydrated four quick slots. diff --git a/work/artifacts/F-182/documenter.md b/work/artifacts/F-182/documenter.md new file mode 100644 index 0000000..c29ef66 --- /dev/null +++ b/work/artifacts/F-182/documenter.md @@ -0,0 +1,3 @@ +# Interfaz táctil TPV + +En Admin → TPV pulsa «Configurar táctil» en un terminal. Activa la navegación y elige hasta cuatro productos rápidos. Categorías, subcategorías y asignaciones de productos se gestionan desde los módulos Catálogo/Categorías. En el TPV, los botones grandes añaden una unidad al carrito; los cuatro accesos rápidos permanecen abajo. diff --git a/work/artifacts/F-182/implementer.md b/work/artifacts/F-182/implementer.md new file mode 100644 index 0000000..af27c38 --- /dev/null +++ b/work/artifacts/F-182/implementer.md @@ -0,0 +1,3 @@ +# F-182 + +Admin → TPV now loads active catalogue options and configures touch mode plus four per-terminal quick slots in `pos_terminals.settings`. New RBAC endpoints validate distinct active variants and return a store-stock-aware touch catalogue. POS shows large category → subcategory → product buttons, breadcrumb/back controls, and four persistent finger-sized quick buttons; search remains available. Runtime: 12 options, config PATCH 200, touch catalog 200 with 10 categories/13 category-product rows/four hydrated quick products. Admin/POS builds and backend typecheck pass. diff --git a/work/artifacts/F-182/leader-close.json b/work/artifacts/F-182/leader-close.json new file mode 100644 index 0000000..df83010 --- /dev/null +++ b/work/artifacts/F-182/leader-close.json @@ -0,0 +1 @@ +{"feature_id":"F-182","agent":"leader","stage":"close","verdict":"APPROVED","checks":[{"item":"all gates/runtime/builds/verify","ok":true}],"issues":[]} diff --git a/work/artifacts/F-182/qa.json b/work/artifacts/F-182/qa.json new file mode 100644 index 0000000..780b63b --- /dev/null +++ b/work/artifacts/F-182/qa.json @@ -0,0 +1 @@ +{"feature_id":"F-182","agent":"qa","stage":"qa_gate","verdict":"APPROVED","checks":[{"item":"config PATCH 200","ok":true},{"item":"catalog GET 200","ok":true},{"item":"four hydrated quick products","ok":true},{"item":"category hierarchy and store stock returned","ok":true},{"item":"Admin/POS builds/typecheck","ok":true}],"issues":[]} diff --git a/work/artifacts/F-182/reviewer.json b/work/artifacts/F-182/reviewer.json new file mode 100644 index 0000000..d8a737f --- /dev/null +++ b/work/artifacts/F-182/reviewer.json @@ -0,0 +1 @@ +{"feature_id":"F-182","agent":"reviewer","stage":"review_gate","verdict":"APPROVED","checks":[{"item":"Admin-owned terminal config","ok":true},{"item":"category/subcategory/product touch flow","ok":true},{"item":"exactly four quick slots","ok":true},{"item":"runtime/builds","ok":true}],"issues":[]} diff --git a/work/artifacts/F-182/security.json b/work/artifacts/F-182/security.json new file mode 100644 index 0000000..84e6c91 --- /dev/null +++ b/work/artifacts/F-182/security.json @@ -0,0 +1 @@ +{"feature_id":"F-182","agent":"security","stage":"security_gate","verdict":"APPROVED","checks":[{"item":"Admin RBAC for config","ok":true},{"item":"staff RBAC and bound terminal for catalog","ok":true},{"item":"active distinct variant validation","ok":true},{"item":"parameterized SQL","ok":true}],"issues":[]} diff --git a/work/current.md b/work/current.md index d7a99f9..2202270 100644 --- a/work/current.md +++ b/work/current.md @@ -1,3 +1,3 @@ -# F-171 — Transactional email audit +# F-182 — Touch category navigation and quick products -Ensure account welcome, password reset, payment success/order status, payment failure and generic notification dispatch all use SMTP settings. Add a real settings-backed notifications provider and verify representative messages to info@rikrdo.es. +Admin → TPV configures touch mode and exactly four optional quick-product slots per terminal. POS derives large category/subcategory/product buttons from the managed catalogue and keeps four bottom quick slots. Search remains available. diff --git a/work/runtime-status.json b/work/runtime-status.json index d607098..69f4377 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -1,64 +1,64 @@ { - "feature_id": "F-171", + "feature_id": "F-182", "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:20:34Z", + "updated_at": "2026-08-22T17:25:12Z", "timeline": [ { - "ts": "2026-08-22T17:18:38Z", + "ts": "2026-08-22T17:21:28Z", "agent": "leader", "stage": "intake", "state": "running", - "message": "Audit all transactional email delivery" + "message": "Build Admin-configured touch product navigation" }, { - "ts": "2026-08-22T17:18:38Z", + "ts": "2026-08-22T17:21:28Z", "agent": "architect", "stage": "design", "state": "running", - "message": "Centralize settings-backed SMTP delivery" + "message": "Design terminal settings and touch catalogue endpoint" }, { - "ts": "2026-08-22T17:18:38Z", + "ts": "2026-08-22T17:21:28Z", "agent": "implementer", "stage": "build", "state": "running", - "message": "Implement SMTP provider and missing failure event" + "message": "Implement Admin config and touch register" }, { - "ts": "2026-08-22T17:20:33Z", + "ts": "2026-08-22T17:25:12Z", "agent": "reviewer", "stage": "review_gate", "state": "running", "message": "review" }, { - "ts": "2026-08-22T17:20:33Z", + "ts": "2026-08-22T17:25:12Z", "agent": "security", "stage": "security_gate", "state": "running", "message": "security" }, { - "ts": "2026-08-22T17:20:33Z", + "ts": "2026-08-22T17:25:12Z", "agent": "qa", "stage": "qa_gate", "state": "running", "message": "qa" }, { - "ts": "2026-08-22T17:20:33Z", + "ts": "2026-08-22T17:25:12Z", "agent": "documenter", "stage": "document", "state": "running", "message": "document" }, { - "ts": "2026-08-22T17:20:34Z", + "ts": "2026-08-22T17:25:12Z", "agent": "leader", "stage": "close", "state": "running",