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

@@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
import type { Product, Brand, Category } from '@/types';
import { productsApi, brandsApi, categoriesApi } from '@/lib/api-client';
import { ImagesSection } from './sections/ImagesSection';
import { PriceStockSection } from './sections/PriceStockSection';
import { PriceStockSection, type PriceStockHandle } from './sections/PriceStockSection';
import LexicalEditor from '@/features/cms/components/LexicalEditor';
interface ProductEditorProps {
@@ -71,6 +71,10 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp
const [expirationDate, setExpirationDate] = useState('');
const [brands, setBrands] = useState<Brand[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
/** Imperative handle for PriceStockSection; called from handleSave so all
* price/stock/EAN/meta persistence flows through one click on the main
* "Guardar cambios" button (F-137). */
const priceStockRef = useRef<PriceStockHandle>(null);
useEffect(() => {
brandsApi.list().then(({ items }) => setBrands(items ?? [])).catch(() => {});
@@ -172,14 +176,34 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp
setDesc(saved.description ?? '');
setSeoTitle(saved.seoTitle ?? '');
setSeoDesc(saved.seoDescription ?? '');
setSuccess(isCreate ? '¡Producto creado! Contenido generado con IA.' : 'Cambios guardados. Contenido generado con IA.');
} catch (generationError) {
setError(generationError instanceof Error ? generationError.message : 'No se pudieron generar los campos SEO');
} finally {
setGenerating(false);
}
} else {
setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados');
}
// Persist price/stock/EAN/meta through the section's saveAll handle
// (F-137: single save point). Tolerates partial failures.
try {
const result = await priceStockRef.current?.saveAll();
const failed = result
? Object.entries(result).filter(([, ok]) => !ok).map(([k]) => k)
: [];
if (failed.length > 0) {
const labels: Record<string, string> = {
price: 'precio',
stock: 'stock',
ean: 'EAN',
meta: 'peso/compra mínima',
};
setSuccess(`Producto guardado. Revisa ${failed.map((k) => labels[k] ?? k).join(', ')}.`);
} else if (!hasMeaningfulContent(desc) || !seoTitle.trim() || !seoDesc.trim()) {
setSuccess(isCreate ? '¡Producto creado! Contenido generado con IA.' : 'Cambios guardados. Contenido generado con IA.');
} else {
setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados');
}
} catch (saveErr) {
setError(saveErr instanceof Error ? saveErr.message : 'Error al guardar precio/stock/EAN');
}
snapRef.current = getSnap();
dirtyRef.current = false;
@@ -279,7 +303,7 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp
</label>
</div>
{/* Precio y stock por producto (entre nombre y metadatos) */}
<PriceStockSection productId={productId} />
<PriceStockSection ref={priceStockRef} productId={productId} />
{/* Marca / Canal / Caducidad — antes de descripción (F-123) */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<div>

View File

@@ -1,8 +1,24 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
import { productsApi, pricingApi, inventoryApi, taxApi, type TaxRate } from '@/lib/api-client';
import type { ProductVariant } from '@/types';
/** Imperative handle exposed by PriceStockSection to ProductEditor.
* All persistence for this section is centralized in saveAll() and called
* by the parent's main "Guardar cambios" button (F-137). */
export interface PriceStockHandle {
/** Persist price, stock, EAN, peso unitario and compra mínima.
* Per-group failures are caught and reported; the function never throws
* for a single-group failure, only for catastrophic errors (e.g., no
* variant id available). Returns per-group success map. */
saveAll(): Promise<{
price: boolean;
stock: boolean;
ean: boolean;
meta: boolean;
}>;
}
/** Convierte céntimos a string de euros ("12.00"). */
function centsToEur(cents: number): string {
return (cents / 100).toFixed(2);
@@ -24,7 +40,10 @@ 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 const PriceStockSection = forwardRef<PriceStockHandle, { productId?: string }>(function PriceStockSection(
{ productId },
ref,
) {
const pending = !productId;
const [variant, setVariant] = useState<ProductVariant | null>(null);
const [loadError, setLoadError] = useState('');
@@ -36,17 +55,14 @@ export function PriceStockSection({ productId }: { productId?: string }) {
const [offer, setOffer] = useState('');
const [vatRate, setVatRate] = useState<VatRate>('general');
const [taxRates, setTaxRates] = useState<TaxRate[]>([]);
const [savingPrice, setSavingPrice] = useState(false);
const [priceMsg, setPriceMsg] = useState('');
// Stock
const [stock, setStock] = useState('');
const [savingStock, setSavingStock] = useState(false);
const [stockMsg, setStockMsg] = useState('');
// EAN
const [ean, setEan] = useState('');
const [savingEan, setSavingEan] = useState(false);
const [eanMsg, setEanMsg] = useState('');
// Peso y compra mínima (F-102)
@@ -58,7 +74,6 @@ export function PriceStockSection({ productId }: { productId?: string }) {
const [unitWeightGr, setUnitWeightGr] = useState('1');
const [, setCustomWeightGr] = useState(''); // legacy, mantenida para no romper el handler
const [minPurchaseQty, setMinPurchaseQty] = useState('1');
const [savingProductMeta, setSavingProductMeta] = useState(false);
const [metaMsg, setMetaMsg] = useState('');
useEffect(() => {
@@ -137,95 +152,135 @@ export function PriceStockSection({ productId }: { productId?: string }) {
setGross(centsToEur(Math.round(eurToCents(net) * (1 + rateP / 100))));
};
const savePrice = async () => {
if (!variant) return;
/** Persist PVP/IVA/Coste/Oferta/Neto via pricingApi. Returns ok=false on
* validation or network failure; sets priceMsg with the error text so
* the caller can surface it next to the section heading. */
const savePrice = async (): Promise<boolean> => {
if (!variant) return false;
const grossCents = eurToCents(gross);
const netCents = Math.round(grossCents / (1 + rateFor(vatRate) / 100));
if (grossCents < 0 || netCents < 0) return;
if (grossCents < 0 || netCents < 0) { setPriceMsg('Precio inválido'); return false; }
const offerCents = offer.trim() ? eurToCents(offer) : null;
const costCents = cost.trim() ? eurToCents(cost) : null;
if (offerCents !== null && offerCents < 0) return;
if (costCents !== null && costCents < 0) return;
setSavingPrice(true);
setPriceMsg('');
if (offerCents !== null && offerCents < 0) { setPriceMsg('Oferta inválida'); return false; }
if (costCents !== null && costCents < 0) { setPriceMsg('Coste inválido'); return false; }
try {
const updated = await pricingApi.setVariantPrice(variant.id, netCents, vatRate, offerCents, costCents);
setNet(centsToEur(updated.netUnitAmountCents));
setPriceMsg('✓ Guardado');
setTimeout(() => setPriceMsg(''), 3000);
} catch {
setPriceMsg('Error al guardar precio');
} finally {
setSavingPrice(false);
setPriceMsg('');
return true;
} catch (error) {
setPriceMsg(error instanceof Error ? error.message : 'Error al guardar precio');
return false;
}
};
const saveStock = async () => {
if (!variant) return;
const saveStock = async (): Promise<boolean> => {
if (!variant) return false;
const qty = parseInt(stock, 10);
if (isNaN(qty) || qty < 0) {
setStockMsg('Stock inválido');
return;
return false;
}
setSavingStock(true);
setStockMsg('');
try {
const result = await inventoryApi.setStock(variant.id, qty);
setStock(String(result.available));
setStockMsg('');
setTimeout(() => setStockMsg(''), 3000);
} catch {
setStockMsg('Error');
} finally {
setSavingStock(false);
setStockMsg('');
return true;
} catch (error) {
setStockMsg(error instanceof Error ? error.message : 'Error al guardar stock');
return false;
}
};
const saveEan = async () => {
if (!variant) return;
const saveEan = async (): Promise<boolean> => {
if (!variant) return false;
const next = ean.trim();
if (next === (variant.ean ?? '')) return;
setSavingEan(true);
setEanMsg('');
if (next === (variant.ean ?? '')) return true; // no-op, count as success
try {
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('');
setTimeout(() => setEanMsg(''), 3000);
setEanMsg('');
return true;
} catch (error) {
setEanMsg(error instanceof Error && error.message.includes('409') ? 'EAN duplicado' : 'Error');
} finally {
setSavingEan(false);
const msg = error instanceof Error
? (error.message.includes('409') ? 'EAN duplicado' : error.message)
: 'Error al guardar EAN';
setEanMsg(msg);
return false;
}
};
const saveProductMeta = async () => {
if (pending || !productId) return;
const saveProductMeta = async (): Promise<boolean> => {
if (pending || !productId) return false;
const gr = parseFloat(unitWeightGr.replace(',', '.'));
const weight = gr / 1000;
const minQty = parseInt(minPurchaseQty, 10);
if (isNaN(gr) || gr <= 0 || gr > 100000) {
setMetaMsg('Peso inválido');
return;
return false;
}
if (isNaN(minQty) || minQty < 1 || minQty > 999) {
setMetaMsg('Compra mínima inválida');
return;
return false;
}
setSavingProductMeta(true);
setMetaMsg('');
try {
await productsApi.update(productId as string, { unitWeightKg: weight, minPurchaseQty: minQty });
setMetaMsg('');
setTimeout(() => setMetaMsg(''), 3000);
} catch {
setMetaMsg('Error');
} finally {
setSavingProductMeta(false);
setMetaMsg('');
return true;
} catch (error) {
setMetaMsg(error instanceof Error ? error.message : 'Error al guardar peso/compra mínima');
return false;
}
};
/** Resolve a variant id; in create flow the section's load effect may
* not have run yet, so we fetch it once on demand. Idempotent. */
const ensureVariant = async (): Promise<ProductVariant | null> => {
if (variant) return variant;
if (!productId) return null;
try {
const { items } = await productsApi.getVariants(productId);
const first = items?.[0] ?? null;
if (first) setVariant(first);
return first;
} catch {
return null;
}
};
/** Imperative API exposed to ProductEditor. Called from the main
* "Guardar cambios" handler. Persists all four groups in sequence;
* per-group failures are caught and reported, never throw.
* The save* helpers close over the state values listed in the deps
* array, so the imperative handle always reflects the latest form
* contents. Including the helpers themselves would also work but
* recreates the handle on every render. */
useImperativeHandle(ref, () => ({
async saveAll() {
setPriceMsg('');
setStockMsg('');
setEanMsg('');
setMetaMsg('');
// Ensure we have a variant id before any save attempts. If we don't,
// every group fails and we report a single, clear error.
const v = await ensureVariant();
if (!v) {
setPriceMsg('No hay variante asociada al producto todavía.');
return { price: false, stock: false, ean: false, meta: false };
}
const [price, stockOk, ean, meta] = await Promise.all([
savePrice(),
saveStock(),
saveEan(),
saveProductMeta(),
]);
return { price, stock: stockOk, ean, meta };
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- save* helpers close over the deps listed below; tracking them separately would recreate the handle on every render.
}), [variant, productId, gross, net, cost, offer, vatRate, stock, ean, unitWeightGr, minPurchaseQty, taxRates]);
if (loadError) {
return <div className="p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{loadError}</div>;
}
@@ -247,7 +302,7 @@ export function PriceStockSection({ productId }: { productId?: string }) {
<span className="text-xs text-gray-400">Se guardará al crear el producto</span>
)}
{priceMsg && (
<span className={`text-xs ${priceMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{priceMsg}</span>
<span className="text-xs text-red-600">{priceMsg}</span>
)}
</div>
</div>
@@ -308,12 +363,9 @@ export function PriceStockSection({ productId }: { productId?: string }) {
<input
type="number" min={0} value={stock}
onChange={(e) => setStock(e.target.value)}
onBlur={saveStock}
onKeyDown={(e) => { if (e.key === 'Enter') saveStock(); }}
disabled={savingStock}
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"
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"
/>
{stockMsg && <span className={`text-xs shrink-0 ${stockMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{stockMsg}</span>}
{stockMsg && <span className="text-xs shrink-0 text-red-600">{stockMsg}</span>}
</div>
</div>
<div>
@@ -322,13 +374,10 @@ export function PriceStockSection({ productId }: { productId?: string }) {
<input
type="text" value={ean}
onChange={(e) => setEan(e.target.value)}
onBlur={saveEan}
onKeyDown={(e) => { if (e.key === 'Enter') saveEan(); }}
disabled={savingEan}
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"
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"
/>
{eanMsg && <span className={`text-xs shrink-0 ${eanMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{eanMsg}</span>}
{eanMsg && <span className="text-xs shrink-0 text-red-600">{eanMsg}</span>}
</div>
</div>
<div>
@@ -340,15 +389,6 @@ export function PriceStockSection({ productId }: { productId?: string }) {
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"
/>
</div>
<div className="flex items-end">
<button
onClick={savePrice}
disabled={savingPrice || pending}
className="w-full px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
{savingPrice ? 'Guardando…' : 'Guardar precio'}
</button>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
@@ -367,11 +407,8 @@ export function PriceStockSection({ productId }: { productId?: string }) {
setUnitWeightGr(e.target.value);
setCustomWeightGr('');
}}
onBlur={saveProductMeta}
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
disabled={savingProductMeta}
placeholder="Gramos"
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"
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"
/>
<datalist id="weight-presets-list">
{WEIGHT_PRESETS_GR.map(g => (
@@ -385,12 +422,9 @@ export function PriceStockSection({ productId }: { productId?: string }) {
<input
type="number" min={1} max={999} value={minPurchaseQty}
onChange={(e) => setMinPurchaseQty(e.target.value)}
onBlur={saveProductMeta}
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
disabled={savingProductMeta}
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"
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"
/>
{metaMsg && <span className={`text-xs shrink-0 ${metaMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{metaMsg}</span>}
{metaMsg && <span className="text-xs shrink-0 text-red-600">{metaMsg}</span>}
</div>
</div>
<div className="col-span-2 flex items-end">
@@ -402,4 +436,4 @@ export function PriceStockSection({ productId }: { productId?: string }) {
</div>
</div>
);
}
});