7.7 KiB
7.7 KiB
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.
PriceStockSectionalready loads its own data (useEffectonproductId). Keep that. - C-2.
ProductEditorowns the mainhandleSaveand the dirty-check snapshot. Don't break the dirty indicator. - C-3. In create flow, when
productsApi.createreturns a new productId,PriceStockSectionenters "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:
// 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:
- Reuses the existing save functions (
savePrice,saveStock,saveEan,saveProductMeta) — no business logic duplication. - Fetches the variant id on demand if
variant === null(create flow: the section'suseEffectmay not have fired yet). It does this by callingproductsApi.getVariants(productId)and grabbingitems[0].id. Idempotent — if the section already loaded the variant, the call is skipped. - Returns a per-group success map so
ProductEditorcan 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 preciobutton (line ~345–350). - Remove
onBlur={saveStock},onBlur={saveEan},onBlur={saveProductMeta}, and the matchingonKeyDown={Enter}handlers on those inputs. State stays local; persistence happens via the main button. - Remove per-field
stockMsg/eanMsg/metaMsg/priceMsgsuccess flashes. They're meaningless now that save is centralized. Replace with a singlesaveStatusindicator 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
handleSavecallspriceStockRef.current?.saveAll()afterproductsApi.update/createsucceeds.- If
saveAll()throws, seterrorwith the failure summary. If it returns partial success, setsuccesswith a "guardado (algunos cambios no)" hint. - The
priceMsgslot inPriceStockSectionis now reserved for the per-group failure message so the user knows which field failed.
Sequencing in handleSave
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),saveAllreturns{ 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.saveAlldoesproductsApi.getVariants(productId)once to obtain the variant id, then proceeds. Idempotent. - Network failure mid-save: the per-group try/catch in
saveAllensures one group's failure doesn't block the others. Aggregate error surfaced to the user. - Dirty tracking: the dirty snapshot in
ProductEditordoes NOT include price/stock/EAN/meta values (those live inPriceStockSection). To keep the dirty indicator honest,PriceStockSectionshould expose anisDirty()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 preciobutton 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 cambiospersists 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 cambiosbecomes enabled/disabled) keeps working for the product payload.