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

139 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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