feat(F-137): completed feature

This commit is contained in:
chattie
2026-08-21 21:03:28 +02:00
parent 8ef3faad42
commit 852b1c1873
60 changed files with 916 additions and 292 deletions

View File

@@ -0,0 +1,138 @@
# 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.