# 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.
