feat(F-123): completed feature

This commit is contained in:
chattie
2026-08-21 17:36:50 +02:00
parent 7da9cad664
commit 3058c81b31
19 changed files with 378 additions and 63 deletions

View File

@@ -278,22 +278,9 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp
<span className="text-xs text-gray-400 hidden sm:inline">(aparece en la home)</span>
</label>
</div>
{/* Precio y stock por producto (entre nombre y descripción) */}
{productId ? (
<PriceStockSection productId={productId} />
) : (
<div className="p-4 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para configurar precio, stock y EAN.
</div>
)}
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
<LexicalEditor
value={desc}
onChange={(html) => setDesc(html)}
placeholder="Descripción detallada del producto…"
/>
</div>
{/* Precio y stock por producto (entre nombre y metadatos) */}
<PriceStockSection productId={productId} />
{/* Marca / Canal / Caducidad — antes de descripción (F-123) */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Marca</label>
@@ -323,6 +310,14 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp
<p className="mt-1 text-xs text-gray-400">Se mostrará en el listado de productos y en la tienda.</p>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
<LexicalEditor
value={desc}
onChange={(html) => setDesc(html)}
placeholder="Descripción detallada del producto…"
/>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-semibold text-gray-900">Categorías</label>
@@ -369,13 +364,7 @@ export function ProductEditor({ productId: initialProductId }: ProductEditorProp
{/* ── IMAGES ── */}
{tab === 'images' && (
<section>
{!productId ? (
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para subir imágenes.
</div>
) : (
<ImagesSection productId={productId} />
)}
<ImagesSection productId={productId} />
</section>
)}

View File

@@ -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<HTMLInputElement>(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 <div className="p-4 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
Guarda primero el producto para gestionar imágenes.
</div>;
// Pending state: render UI with disabled actions and a caption.
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-gray-900">Imágenes</h3>
<span className="text-xs text-gray-400">Se guardarán al crear el producto</span>
</div>
<div className="flex flex-col gap-3 sm:flex-row opacity-50 pointer-events-none">
<input
type="text"
value=""
readOnly
placeholder="Pega una URL de imagen..."
className="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-sm bg-gray-50"
/>
<button
type="button"
disabled
className="px-5 py-2.5 bg-[#2D6A4F] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
>
Añadir URL
</button>
<button
type="button"
disabled
className="px-5 py-2.5 border border-gray-300 text-gray-700 text-sm font-medium rounded-xl transition-colors"
>
📤 Subir imagen
</button>
</div>
<div className="border-2 border-dashed border-gray-200 rounded-xl p-8 text-center">
<p className="text-gray-400 text-sm">
🖼 Arrastra imágenes aquí para añadirlas al producto
</p>
</div>
</div>
);
}
if (loading) return <div className="p-8 text-gray-400 text-sm">Cargando imágenes...</div>;

View File

@@ -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<ProductVariant | null>(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 <div className="p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{loadError}</div>;
}
if (!variant) {
if (!pending && !variant) {
return (
<div className="p-4 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
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 }) {
<div className="border border-gray-200 rounded-xl p-5 space-y-4 bg-gray-50/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-gray-900">Precio y stock</h3>
{priceMsg && (
<span className={`text-xs ${priceMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{priceMsg}</span>
)}
<div className="flex items-center gap-3">
{pending && (
<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>
)}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
@@ -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 && <span className={`text-xs shrink-0 ${stockMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{stockMsg}</span>}
@@ -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 }) {
<div className="flex items-end">
<button
onClick={savePrice}
disabled={savingPrice}
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'}
@@ -335,16 +368,30 @@ export function PriceStockSection({ productId }: { productId: string }) {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Peso unitario (kg)</label>
<input
type="text" inputMode="decimal" value={unitWeightKg}
onChange={(e) => setUnitWeightKg(e.target.value)}
<label className="block text-xs font-semibold text-gray-600 mb-1">Peso unitario (Gr)</label>
<select
value={weightPresetValue}
onChange={(e) => onWeightPresetChange(e.target.value)}
onBlur={saveProductMeta}
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
disabled={savingProductMeta}
placeholder="1"
disabled={savingProductMeta || 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"
/>
>
{WEIGHT_PRESETS_GR.map(g => (
<option key={g} value={String(g)}>{g} g</option>
))}
<option value="custom">Personalizado</option>
</select>
{weightIsCustom && (
<input
type="text" inputMode="decimal" value={customWeightGr || unitWeightGr}
onChange={(e) => { setCustomWeightGr(e.target.value); setUnitWeightGr(e.target.value); }}
onBlur={saveProductMeta}
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
disabled={savingProductMeta || pending}
placeholder="Gramos"
className="mt-2 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"
/>
)}
</div>
<div>
<label className="block text-xs font-semibold text-gray-600 mb-1">Compra mínima (uds.)</label>
@@ -354,7 +401,7 @@ export function PriceStockSection({ productId }: { productId: string }) {
onChange={(e) => setMinPurchaseQty(e.target.value)}
onBlur={saveProductMeta}
onKeyDown={(e) => { if (e.key === 'Enter') saveProductMeta(); }}
disabled={savingProductMeta}
disabled={savingProductMeta || 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"
/>
{metaMsg && <span className={`text-xs shrink-0 ${metaMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{metaMsg}</span>}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB