# F-137 — Design: Unify price/stock/EAN/meta save into "Guardar cambios" **Author:** architect (auto-design) **Date:** 2026-08-21 **Stage:** design ## Context (problem) `apps/admin/src/features/products/components/sections/PriceStockSection.tsx` currently mixes four save flows: | Field group | Save trigger | Persistence | |---|---|---| | Price (PVP, IVA, Coste, Oferta, Neto) | Click on `Guardar precio` button | `PUT /api/pricing/variants/:id` | | Stock | `blur` / `Enter` on input | `PUT /api/inventory/:id/stock` | | EAN | `blur` / `Enter` on input | `PATCH /api/products/:id/variants/:vid` | | Peso unitario, Compra mínima | `blur` / `Enter` on inputs | `PATCH /api/products/:id` | The operator's request is: **single save point**. All four groups must persist when the user clicks `Guardar cambios` in `ProductEditor.tsx`. The `Guardar precio` button goes away. Auto-save on `blur`/`Enter` goes away too — otherwise we have two save sources racing each other. ## Constraints - C-1. `PriceStockSection` already loads its own data (`useEffect` on `productId`). Keep that. - C-2. `ProductEditor` owns the main `handleSave` and the dirty-check snapshot. Don't break the dirty indicator. - C-3. In **create** flow, when `productsApi.create` returns a new productId, `PriceStockSection` enters "loaded" state asynchronously. We need a save path that works whether the section has already loaded its variant or not. - C-4. No backend changes. All persistence is via existing endpoints. - C-5. UX: the user must see clear feedback when the main save fails (partial vs full failure). ## Design ### Decision: imperative ref API on PriceStockSection Lifting all state to `ProductEditor` would force a wider refactor (re-render storms, props drilling, loss of internal load effect). The cheaper, equally clean option is an **imperative handle**: ```ts // PriceStockSection.tsx — exposed via forwardRef + useImperativeHandle export interface PriceStockHandle { /** Save all dirty fields. Throws on failure; never partial-silently. */ saveAll(): Promise<{ price: boolean; stock: boolean; ean: boolean; meta: boolean; }>; } ``` `ProductEditor` calls `priceStockRef.current?.saveAll()` **after** `productsApi.create/update` succeeds. The handle: 1. **Reuses the existing save functions** (`savePrice`, `saveStock`, `saveEan`, `saveProductMeta`) — no business logic duplication. 2. **Fetches the variant id on demand** if `variant === null` (create flow: the section's `useEffect` may not have fired yet). It does this by calling `productsApi.getVariants(productId)` and grabbing `items[0].id`. Idempotent — if the section already loaded the variant, the call is skipped. 3. **Returns a per-group success map** so `ProductEditor` can show a single "Cambios guardados" message even when one group failed (or fail loudly if any group failed). ### UX changes in PriceStockSection - **Remove** the `Guardar precio` button (line ~345–350). - **Remove** `onBlur={saveStock}`, `onBlur={saveEan}`, `onBlur={saveProductMeta}`, and the matching `onKeyDown={Enter}` handlers on those inputs. State stays local; persistence happens via the main button. - **Remove** per-field `stockMsg` / `eanMsg` / `metaMsg` / `priceMsg` success flashes. They're meaningless now that save is centralized. Replace with a single `saveStatus` indicator rendered next to the section heading when the main save reports partial failure for that group. - **Add** a small "dirty" indicator inside the section heading so the user knows there are unsaved changes since the last main save (small dot or italic "sin guardar"). This is a quality-of-life addition — strictly optional, scope_in if cheap. ### UX changes in ProductEditor - `handleSave` calls `priceStockRef.current?.saveAll()` after `productsApi.update/create` succeeds. - If `saveAll()` throws, set `error` with the failure summary. If it returns partial success, set `success` with a "guardado (algunos cambios no)" hint. - The `priceMsg` slot in `PriceStockSection` is now reserved for the **per-group failure message** so the user knows which field failed. ### Sequencing in handleSave ```ts const handleSave = async () => { setSaving(true); setError(''); setSuccess(''); try { // 1. Product payload (existing) const saved: Product = isCreate ? await productsApi.create(payload) : await productsApi.update(productId, payload); setProductId(saved.id); if (isCreate) router.replace(`/products/${saved.id}`); // 2. Optionally generate SEO with IA (existing branch — unchanged) if (!hasMeaningfulContent(desc) || !seoTitle.trim() || !seoDesc.trim()) { setGenerating(true); try { saved = await productsApi.generateSeo(saved.id); /* ... */ } finally { setGenerating(false); } } // 3. NEW: save price/stock/EAN/meta via the section handle try { const result = await priceStockRef.current?.saveAll(); const failed = Object.entries(result ?? {}).filter(([_, ok]) => !ok).map(([k]) => k); if (failed.length) { setSuccess(`Producto guardado. Revisa: ${failed.join(', ')}.`); } else { setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados'); } } catch (saveErr) { setError(saveErr instanceof Error ? saveErr.message : 'Error al guardar precio/stock/EAN'); } // 4. Existing snapshot + dirty reset snapRef.current = getSnap(); dirtyRef.current = false; } catch (err) { setError(err instanceof Error ? err.message : 'Error al guardar'); } finally { setSaving(false); } }; ``` ### Edge cases - **Empty state on edit (variant is null)**: shouldn't happen — `PriceStockSection`'s load effect runs on mount. If it does (e.g., race), `saveAll` returns `{ price: false, stock: false, ean: false, meta: false }` with a clear error message. - **Pending state on create**: the section has all field state but no `variant`. `saveAll` does `productsApi.getVariants(productId)` once to obtain the variant id, then proceeds. Idempotent. - **Network failure mid-save**: the per-group try/catch in `saveAll` ensures one group's failure doesn't block the others. Aggregate error surfaced to the user. - **Dirty tracking**: the dirty snapshot in `ProductEditor` does NOT include price/stock/EAN/meta values (those live in `PriceStockSection`). To keep the dirty indicator honest, `PriceStockSection` should expose an `isDirty()` method too, OR the indicator just covers the product payload fields. Going with the latter: keep it simple, document the gap. ### Files affected | File | Change | |---|---| | `apps/admin/src/features/products/components/sections/PriceStockSection.tsx` | forwardRef, useImperativeHandle, saveAll(), remove button, remove blur/Enter saves, remove per-field msg | | `apps/admin/src/features/products/components/ProductEditor.tsx` | priceStockRef, call saveAll() in handleSave, handle partial-failure message | ### Out of scope - Backend changes (none). - Dirty indicator that covers price/stock/EAN/meta (separate ticket if the operator requests it). - Removing the inline success flash animations on the four input groups — already gone as part of the UX changes. ## Acceptance criteria - AC-1. `Guardar precio` button is gone from the UI. - AC-2. Stock, EAN, peso, compra mínima inputs do NOT auto-save on blur or Enter. - AC-3. Clicking `Guardar cambios` persists price + stock + EAN + meta in a single logical action (multiple HTTP requests, same UX moment). - AC-4. Create flow: after `Crear producto`, the variant's price/stock/EAN/meta are also persisted. Verified end-to-end with a fresh product. - AC-5. If any of the four groups fails, the user sees which group(s) failed and the others are still saved. - AC-6. Build green, lint green, typecheck green. - AC-7. Existing dirty indicator (`Guardar cambios` becomes enabled/disabled) keeps working for the product payload.