feat(F-137): completed feature
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user