Files
mercadodevida/work/artifacts/F-137/architect.md
2026-08-21 21:03:28 +02:00

7.7 KiB
Raw Blame History

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:

// 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 ~345350).
  • 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

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.