diff --git a/backlog/features.json b/backlog/features.json index 7492b11..39d5217 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -5218,6 +5218,23 @@ "close": true }, "completed_at": "2026-08-21T13:32:24Z" + }, + { + "id": "F-123", + "type": "fix", + "title": "Remove 'Save product first' warning for single-variant products", + "description": "F-121 dejó en ProductEditor.tsx los textos 'Guarda primero el producto para configurar precio, stock y EAN.' (línea 286) y 'Guarda primero el producto para subir imágenes.' (línea 374). El bundle servido en :3004 sigue mostrando la restricción cuando productId es undefined (modo creación). Como todos los productos son single-variant, la restricción no aporta valor. Fix: eliminar los avisos y permitir que PriceStockSection e ImagesSection rendericen siempre, con los botones de guardado deshabilitados y un caption 'Se guardará al crear el producto' hasta que exista productId.", + "priority": "high", + "risk": "low", + "status": "done", + "created_at": "2026-08-21", + "gates": { + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-21T15:36:50Z" } ] } diff --git a/project/apps/admin/src/features/products/components/ProductEditor.tsx b/project/apps/admin/src/features/products/components/ProductEditor.tsx index 42d2ec6..89e7bff 100644 --- a/project/apps/admin/src/features/products/components/ProductEditor.tsx +++ b/project/apps/admin/src/features/products/components/ProductEditor.tsx @@ -278,22 +278,9 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp (aparece en la home) - {/* Precio y stock por producto (entre nombre y descripción) */} - {productId ? ( - - ) : ( -
- ⚠️ Guarda primero el producto para configurar precio, stock y EAN. -
- )} -
- - setDesc(html)} - placeholder="Descripción detallada del producto…" - /> -
+ {/* Precio y stock por producto (entre nombre y metadatos) */} + + {/* Marca / Canal / Caducidad — antes de descripción (F-123) */}
@@ -323,6 +310,14 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp

Se mostrará en el listado de productos y en la tienda.

+
+ + setDesc(html)} + placeholder="Descripción detallada del producto…" + /> +
@@ -369,13 +364,7 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp {/* ── IMAGES ── */} {tab === 'images' && (
- {!productId ? ( -
- ⚠️ Guarda primero el producto para subir imágenes. -
- ) : ( - - )} +
)} diff --git a/project/apps/admin/src/features/products/components/sections/ImagesSection.tsx b/project/apps/admin/src/features/products/components/sections/ImagesSection.tsx index 6f0e844..da29ded 100644 --- a/project/apps/admin/src/features/products/components/sections/ImagesSection.tsx +++ b/project/apps/admin/src/features/products/components/sections/ImagesSection.tsx @@ -4,7 +4,7 @@ import { productsApi } from '@/lib/api-client'; import type { ProductImage } from '@/types'; interface ImagesSectionProps { - productId: string; + productId?: string; } export function ImagesSection({ productId }: ImagesSectionProps) { @@ -18,8 +18,10 @@ export function ImagesSection({ productId }: ImagesSectionProps) { const fileRef = useRef(null); const load = useCallback(async () => { + if (!productId) return; + const pid = productId; try { - const p = await productsApi.get(productId); + const p = await productsApi.get(pid); setImages(p.images ?? []); } catch { setError('Error al cargar imágenes'); @@ -124,9 +126,43 @@ export function ImagesSection({ productId }: ImagesSectionProps) { }; if (!productId) { - return
- ⚠️ Guarda primero el producto para gestionar imágenes. -
; + // Pending state: render UI with disabled actions and a caption. + return ( +
+
+

Imágenes

+ Se guardarán al crear el producto +
+
+ + + +
+
+

+ 🖼️ Arrastra imágenes aquí para añadirlas al producto +

+
+
+ ); } if (loading) return
Cargando imágenes...
; diff --git a/project/apps/admin/src/features/products/components/sections/PriceStockSection.tsx b/project/apps/admin/src/features/products/components/sections/PriceStockSection.tsx index 311da32..677f4eb 100644 --- a/project/apps/admin/src/features/products/components/sections/PriceStockSection.tsx +++ b/project/apps/admin/src/features/products/components/sections/PriceStockSection.tsx @@ -24,7 +24,8 @@ type VatRate = 'general' | 'reduced' | 'super-reduced'; * El modelo interno conserva una única variante por producto; esta sección * opera siempre sobre la primera variante. */ -export function PriceStockSection({ productId }: { productId: string }) { +export function PriceStockSection({ productId }: { productId?: string }) { + const pending = !productId; const [variant, setVariant] = useState(null); const [loadError, setLoadError] = useState(''); @@ -49,27 +50,52 @@ export function PriceStockSection({ productId }: { productId: string }) { const [eanMsg, setEanMsg] = useState(''); // Peso y compra mínima (F-102) - const [unitWeightKg, setUnitWeightKg] = useState('1'); + // Peso se gestiona en gramos (UI: dropdown + custom). Se convierte a kg al persistir. + const WEIGHT_PRESETS_GR = [100, 150, 200, 250, 300, 350, 500, 740] as const; + const [unitWeightGr, setUnitWeightGr] = useState('1'); + const [customWeightGr, setCustomWeightGr] = useState(''); const [minPurchaseQty, setMinPurchaseQty] = useState('1'); const [savingProductMeta, setSavingProductMeta] = useState(false); const [metaMsg, setMetaMsg] = useState(''); + // Determina si el valor actual coincide con un preset o si requiere custom. + const weightIsCustom = !WEIGHT_PRESETS_GR.map(String).includes(unitWeightGr); + const weightPresetValue = weightIsCustom ? 'custom' : unitWeightGr; + + const onWeightPresetChange = (value: string) => { + if (value === 'custom') { + // Mantén el valor actual en customWeightGr para que el usuario no pierda lo escrito. + setCustomWeightGr(unitWeightGr || ''); + // Marca custom sin asignar un valor concreto hasta que el usuario edite. + if (!WEIGHT_PRESETS_GR.map(String).includes(unitWeightGr)) { + setUnitWeightGr(''); + } + } else { + setUnitWeightGr(value); + setCustomWeightGr(''); + } + }; + useEffect(() => { taxApi.list().then(({ items }) => setTaxRates(items.filter((r) => r.active))).catch(() => {}); }, []); useEffect(() => { + if (!productId) return; // pending: nothing to load yet + const pid = productId; let cancelled = false; productsApi - .get(productId) + .get(pid) .then((product) => { if (cancelled) return; - setUnitWeightKg(String((product as unknown as { unitWeightKg?: number }).unitWeightKg ?? 1)); + const kg = (product as unknown as { unitWeightKg?: number }).unitWeightKg ?? 1; + setUnitWeightGr(String(Math.round(kg * 1000))); + setCustomWeightGr(''); setMinPurchaseQty(String((product as unknown as { minPurchaseQty?: number }).minPurchaseQty ?? 1)); }) .catch(() => {}); productsApi - .getVariants(productId) + .getVariants(pid) .then(async ({ items }) => { if (cancelled) return; const first = items?.[0] ?? null; @@ -177,7 +203,7 @@ export function PriceStockSection({ productId }: { productId: string }) { setSavingEan(true); setEanMsg(''); try { - const updated = await productsApi.updateVariant(productId, variant.id, { ean: next || null }); + const updated = await productsApi.updateVariant(productId as string, variant.id, { ean: next || null }); setVariant((prev) => (prev ? { ...prev, ean: updated.ean } : prev)); setEan(updated.ean ?? ''); setEanMsg('✓'); @@ -190,9 +216,11 @@ export function PriceStockSection({ productId }: { productId: string }) { }; const saveProductMeta = async () => { - const weight = parseFloat(unitWeightKg.replace(',', '.')); + if (pending || !productId) return; + const gr = parseFloat(unitWeightGr.replace(',', '.')); + const weight = gr / 1000; const minQty = parseInt(minPurchaseQty, 10); - if (isNaN(weight) || weight <= 0 || weight > 1000) { + if (isNaN(gr) || gr <= 0 || gr > 100000) { setMetaMsg('Peso inválido'); return; } @@ -203,7 +231,7 @@ export function PriceStockSection({ productId }: { productId: string }) { setSavingProductMeta(true); setMetaMsg(''); try { - await productsApi.update(productId, { unitWeightKg: weight, minPurchaseQty: minQty }); + await productsApi.update(productId as string, { unitWeightKg: weight, minPurchaseQty: minQty }); setMetaMsg('✓'); setTimeout(() => setMetaMsg(''), 3000); } catch { @@ -217,7 +245,7 @@ export function PriceStockSection({ productId }: { productId: string }) { return
{loadError}
; } - if (!variant) { + if (!pending && !variant) { return (
⚠️ Este producto aún no tiene datos internos de venta. Recarga la página para generarlos. @@ -229,9 +257,14 @@ export function PriceStockSection({ productId }: { productId: string }) {

Precio y stock

- {priceMsg && ( - {priceMsg} - )} +
+ {pending && ( + Se guardará al crear el producto + )} + {priceMsg && ( + {priceMsg} + )} +
@@ -292,7 +325,7 @@ export function PriceStockSection({ productId }: { productId: string }) { onChange={(e) => setStock(e.target.value)} onBlur={saveStock} onKeyDown={(e) => { if (e.key === 'Enter') saveStock(); }} - disabled={savingStock} + disabled={savingStock || pending} className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50" /> {stockMsg && {stockMsg}} @@ -306,7 +339,7 @@ export function PriceStockSection({ productId }: { productId: string }) { onChange={(e) => setEan(e.target.value)} onBlur={saveEan} onKeyDown={(e) => { if (e.key === 'Enter') saveEan(); }} - disabled={savingEan} + disabled={savingEan || pending} placeholder="8412345678901" className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white disabled:opacity-50" /> @@ -325,7 +358,7 @@ export function PriceStockSection({ productId }: { productId: string }) {