From 23e84efe80707ceb434a91444c6da3db42179d66 Mon Sep 17 00:00:00 2001 From: chattie Date: Thu, 20 Aug 2026 06:04:22 +0200 Subject: [PATCH] feat(F-082): completed feature --- backlog/features.json | 12 +- .../src/app/(dashboard)/inventory/page.tsx | 130 ++++---- .../components/sections/InventorySection.tsx | 304 ++++++++++++++---- work/artifacts/F-082/architect.md | 74 +++++ work/artifacts/F-082/implementer.md | 37 +++ work/artifacts/F-082/leader-close.json | 14 + work/artifacts/F-082/qa.json | 21 ++ work/artifacts/F-082/reviewer.json | 17 + work/artifacts/F-082/security.json | 16 + work/runtime-status.json | 20 +- 10 files changed, 510 insertions(+), 135 deletions(-) create mode 100644 work/artifacts/F-082/architect.md create mode 100644 work/artifacts/F-082/implementer.md create mode 100644 work/artifacts/F-082/leader-close.json create mode 100644 work/artifacts/F-082/qa.json create mode 100644 work/artifacts/F-082/reviewer.json create mode 100644 work/artifacts/F-082/security.json diff --git a/backlog/features.json b/backlog/features.json index 98d1b66..eed9d1f 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -3830,13 +3830,15 @@ "No regressions in the existing Prices tab or product editor", "verify.sh is green" ], - "status": "pending", + "status": "done", "created_at": "2026-08-19", "gates": { - "reviewer": false, - "security": false, - "qa": false - } + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-20T04:04:22Z" }, { "id": "F-083", diff --git a/project/apps/admin/src/app/(dashboard)/inventory/page.tsx b/project/apps/admin/src/app/(dashboard)/inventory/page.tsx index b29e164..35b53b1 100644 --- a/project/apps/admin/src/app/(dashboard)/inventory/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/inventory/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; import { productsApi, inventoryApi } from '@/lib/api-client'; -import type { Product, ProductVariant, StockAvailability } from '@/types'; +import type { ProductVariant, StockAvailability } from '@/types'; interface VariantRow { productId: string; @@ -140,6 +140,56 @@ export default function InventoryPage() { } }; + // Save Stock inline (used by Stock cell on blur/Enter) + const saveStockInline = async (variantId: string, value: string) => { + const qty = parseInt(value, 10); + if (isNaN(qty) || qty < 0) { + setRows((prev) => + prev.map((r) => + r.variant.id === variantId + ? { ...r, editing: false, editValue: String(r.stock?.availableQuantity ?? 0), msg: 'Error' } + : r, + ), + ); + return; + } + setRows((prev) => prev.map((r) => (r.variant.id === variantId ? { ...r, saving: true } : r))); + try { + const result = await inventoryApi.setStock(variantId, qty); + setRows((prev) => + prev.map((r) => + r.variant.id === variantId + ? { + ...r, + stock: { available: result.available > 0, availableQuantity: result.available }, + editing: false, + saving: false, + editValue: String(result.available), + msg: '✓ Guardado', + } + : r, + ), + ); + setTimeout(() => { + setRows((prev) => prev.map((r) => (r.variant.id === variantId ? { ...r, msg: '' } : r))); + }, 3000); + } catch { + setRows((prev) => + prev.map((r) => + r.variant.id === variantId + ? { + ...r, + saving: false, + editing: false, + editValue: String(r.stock?.availableQuantity ?? 0), + msg: 'Error', + } + : r, + ), + ); + } + }; + // Filter rows const filtered = rows.filter((r) => { if (filter === 'in_stock') return (r.stock?.availableQuantity ?? 0) >= 5; @@ -302,8 +352,9 @@ export default function InventoryPage() { {row.loading ? ( ) : row.editing ? ( -
+
- - + className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none" + /> + {row.msg && ( + + {row.msg} + + )}
) : ( + )} + {/* EAN */} - {variant.ean ?? '—'} + + {r.editingEan ? ( + + setRows((prev) => ({ + ...prev, + [variant.id]: { ...prev[variant.id], eanValue: e.target.value }, + })) + } + onBlur={() => saveEan(variant.id, productId)} + onKeyDown={(e) => { + if (e.key === 'Enter') saveEan(variant.id, productId); + if (e.key === 'Escape') + setRows((prev) => ({ + ...prev, + [variant.id]: { + ...prev[variant.id], + editingEan: false, + eanValue: prev[variant.id].variant.ean ?? '', + }, + })); + }} + disabled={r.savingEan} + placeholder="—" + className="w-full px-2 py-1 border border-[#2D6A4F] rounded text-xs font-mono focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50" + /> + ) : ( + + )} + {/* Precio */} @@ -315,6 +523,7 @@ export function InventorySection({ productId }: InventorySectionProps) {
savePrice(variant.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') savePrice(variant.id); + if (e.key === 'Escape') + cancelEditPrice(variant.id); + }} className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none" />
) : ( -
- - {r.price ? formatCents(r.price.netUnitAmountCents) : '—'} - - {r.price && ( - - )} -
+ )} @@ -379,6 +587,7 @@ export function InventorySection({ productId }: InventorySectionProps) { ) : r.editingStock ? (
- - + className="w-16 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50" + />
) : ( -
- - {r.stock?.availableQuantity ?? '—'} - - -
+ )} @@ -427,24 +624,7 @@ export function InventorySection({ productId }: InventorySectionProps) { available={r.stock?.available ?? false} quantity={r.stock?.availableQuantity ?? 0} /> - {r.editingPrice && ( - - )} - {r.editingPrice && ( - - )} - {saveMsg[variant.id] && !r.editingStock && !r.editingPrice && ( + {saveMsg[variant.id] && !r.editingStock && !r.editingPrice && !r.editingSku && !r.editingEan && ( {saveMsg[variant.id]} diff --git a/work/artifacts/F-082/architect.md b/work/artifacts/F-082/architect.md new file mode 100644 index 0000000..e786e19 --- /dev/null +++ b/work/artifacts/F-082/architect.md @@ -0,0 +1,74 @@ +# F-082 — Architect: /inventory editable fields cannot be saved; Stock UX needs click-to-edit + +## Root cause + +Two views both have inline-edit affordances with subtle UX gaps: + +### A) Standalone `/inventory` page (`apps/admin/src/app/(dashboard)/inventory/page.tsx`) +- **SKU** and **EAN** are already click-to-edit and save on blur/Enter via `handleSaveSku`/`handleSaveEan`. ✅ +- **Stock** opens edit mode on click but the operator still has to click an **OK** button to persist. Cancel is via ✕. This violates the desired "save on blur or Enter" UX. + +### B) `InventorySection.tsx` (inventory tab inside the product editor) +- **Precio neto**: opens via ✏️ pencil, saves via OK button (no Enter/blur save). +- **Stock**: opens via ✏️ pencil, saves via OK button. +- **SKU** and **EAN**: not editable at all (plain text in the table) — silent loss if anyone tries. + +## Design + +Standardise on **click-to-edit + save on blur/Enter** for every editable cell. Remove pencil icons and OK buttons where they exist. Pattern: + +``` + + ↳ swaps to + save(id)} + onKeyDown={e => { if (e.key === 'Enter') save(id); if (e.key === 'Escape') cancel(id); }} + disabled={saving} +/> +``` + +### Files / changes + +1. `apps/admin/src/app/(dashboard)/inventory/page.tsx` + - Stock cell: drop the OK button and ✕ button. Save fires on blur and Enter; cancel on Escape restores the original stock value. + - Existing `msg` indicator shows the save state. + - SKU and EAN unchanged (they already work). + +3. `apps/admin/src/features/products/components/sections/InventorySection.tsx` + - **Stock**: replace ✏️ + OK + ✕ pattern with the same click-to-edit + save on blur/Enter pattern. + - **Precio neto**: same — drop OK and ✕, save on Enter/blur. + - **SKU**: add inline editing (was plain text). Click cell → input → save on blur/Enter → `productsApi.updateVariant(productId, variantId, { sku })`. + - **EAN**: add inline editing (was plain text). Same mechanism; `null` when empty. + - **IVA select**: stays as-is (already click-to-edit without explicit pencil). + +### Persistence + +All edits call existing PATCH endpoints: +- `productsApi.updateVariant(productId, variantId, { sku | ean })` +- `pricingApi.setVariantPrice(variantId, cents, vatRate)` +- `inventoryApi.setStock(variantId, qty)` + +No new endpoints, no schema changes. + +### Failure handling + +- On API failure the existing inline `saveMsg` shows `Error`; cell reverts via `cancelEdit*` which restores the value from the row state. +- The previous value is held in state until the API response arrives so the cell can revert cleanly. + +### Concurrency + +A simple `saving` flag per cell disables the input and prevents double-submit. Last-write-wins on the backend (existing behaviour). + +## Risk + +Low. Pure UX refactor, no API contract change. + +## Acceptance mapping +- "Every editable cell in /inventory saves on blur or Enter via PATCH /variants/:id" → all cells adopt the click-to-edit + onBlur/onKeyDown pattern. +- "Stock cell enters edit mode on a single click; the pencil icon is removed" → button value is the cell value; no pencil. +- "Successful save shows visual confirmation" → `msg` chip shows ✓. +- "Failed save shows inline error and restores the previous value" → saveMsg + cancelEdit path. +- "No regressions in the existing Prices tab or product editor" → changes scoped to InventorySection + /inventory page. +- "verify.sh is green" → typecheck + lint clean. \ No newline at end of file diff --git a/work/artifacts/F-082/implementer.md b/work/artifacts/F-082/implementer.md new file mode 100644 index 0000000..4e9034c --- /dev/null +++ b/work/artifacts/F-082/implementer.md @@ -0,0 +1,37 @@ +# F-082 — Implementer evidence + +## What was implemented + +Two inventory surfaces got the same click-to-edit + save-on-Enter/blur UX. Removed the pencil step and the OK button pattern. + +### Files changed + +- `project/apps/admin/src/app/(dashboard)/inventory/page.tsx` + - Added `saveStockInline(variantId, value)` helper that saves on Enter/blur and restores the previous value on Escape or error. + - Stock cell: dropped the OK button and ✕ button. The cell itself is a `