feat(F-137): completed feature
This commit is contained in:
138
work/artifacts/F-137/architect.md
Normal file
138
work/artifacts/F-137/architect.md
Normal 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 ~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.
|
||||
Reference in New Issue
Block a user