feat(F-108): completed feature
This commit is contained in:
@@ -4720,13 +4720,15 @@
|
||||
"Inventory lists products not variants",
|
||||
"verify.sh is green"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"created_at": "2026-08-21",
|
||||
"gates": {
|
||||
"reviewer": false,
|
||||
"security": false,
|
||||
"qa": false
|
||||
}
|
||||
"reviewer": true,
|
||||
"security": true,
|
||||
"qa": true,
|
||||
"close": true
|
||||
},
|
||||
"completed_at": "2026-08-21T07:57:11Z"
|
||||
},
|
||||
{
|
||||
"id": "F-109",
|
||||
@@ -4819,6 +4821,35 @@
|
||||
"security": false,
|
||||
"qa": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "F-112",
|
||||
"type": "feature",
|
||||
"title": "Label AI-generated content with disclaimer on product pages",
|
||||
"problem": "User-visible AI generated content is not disclosed",
|
||||
"goal": "Show disclaimer note on frontend product pages when content was AI generated or assisted",
|
||||
"scope_in": [
|
||||
"frontend product detail",
|
||||
"AI content flags"
|
||||
],
|
||||
"scope_out": [
|
||||
"no admin AI flow changes"
|
||||
],
|
||||
"priority": "high",
|
||||
"risk": "low",
|
||||
"description": "Problem: User-visible AI generated content is not disclosed. Goal: Show disclaimer note on frontend product pages when content was AI generated or assisted. Scope IN: frontend product detail, AI content flags. Scope OUT: no admin AI flow changes. Type: feature. Priority: high. Risk: low.",
|
||||
"acceptance": [
|
||||
"Product page shows note: Parte del contenido de esta ficha puede haber sido generado o asistido mediante inteligencia artificial y revisado antes de su publicacion",
|
||||
"Note also warns: composicion y caracteristicas pueden cambiar, consultar etiqueta del fabricante",
|
||||
"verify.sh is green"
|
||||
],
|
||||
"status": "pending",
|
||||
"created_at": "2026-08-21",
|
||||
"gates": {
|
||||
"reviewer": false,
|
||||
"security": false,
|
||||
"qa": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -63,8 +63,12 @@ export default function InventoryPage() {
|
||||
const variantRows: VariantRow[] = [];
|
||||
|
||||
for (const product of products ?? []) {
|
||||
// Modelo sin variantes: cada producto gestiona su stock/EAN a nivel de
|
||||
// producto usando su única variante interna (F-108).
|
||||
const { items: variants } = await productsApi.getVariants(product.id);
|
||||
for (const variant of variants ?? []) {
|
||||
const variant = variants?.[0];
|
||||
if (!variant) continue;
|
||||
{
|
||||
variantRows.push({
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
|
||||
@@ -4,9 +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 { InventorySection } from './sections/InventorySection';
|
||||
import { PricingSection } from './sections/PricingSection';
|
||||
import { VariantManager } from './sections/VariantManager';
|
||||
import { PriceStockSection } from './sections/PriceStockSection';
|
||||
import LexicalEditor from '@/features/cms/components/LexicalEditor';
|
||||
|
||||
interface ProductEditorProps {
|
||||
@@ -49,7 +47,7 @@ function hasMeaningfulContent(value: string): boolean {
|
||||
export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
const router = useRouter();
|
||||
const isCreate = !productId;
|
||||
const [tab, setTab] = useState<'general' | 'pricing' | 'inventory' | 'images' | 'seo' | 'publish'>('general');
|
||||
const [tab, setTab] = useState<'general' | 'images' | 'seo' | 'publish'>('general');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [loading, setLoading] = useState(!isCreate);
|
||||
@@ -237,12 +235,12 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 mb-8 overflow-x-auto">
|
||||
{(['general', 'pricing', 'inventory', 'images', 'seo', 'publish'] as const).map(t => (
|
||||
{(['general', 'images', 'seo', 'publish'] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
tab === t ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}>
|
||||
{t === 'general' ? 'General' : t === 'pricing' ? 'Precios' : t === 'inventory' ? 'Inventario' : t === 'images' ? 'Imágenes' : t === 'seo' ? 'SEO' : 'Publicar'}
|
||||
{t === 'general' ? 'General' : t === 'images' ? 'Imágenes' : t === 'seo' ? 'SEO' : 'Publicar'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -273,6 +271,14 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
<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
|
||||
@@ -353,32 +359,6 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── PRICING ── */}
|
||||
{tab === 'pricing' && (
|
||||
<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 configurar precios.
|
||||
</div>
|
||||
) : (
|
||||
<PricingSection productId={productId} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── INVENTORY ── */}
|
||||
{tab === 'inventory' && (
|
||||
<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 gestionar inventario.
|
||||
</div>
|
||||
) : (
|
||||
<InventorySection productId={productId} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── IMAGES ── */}
|
||||
{tab === 'images' && (
|
||||
<section>
|
||||
@@ -433,13 +413,6 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
{/* ── PUBLISH ── */}
|
||||
{tab === 'publish' && (
|
||||
<section className="space-y-5">
|
||||
{productId ? (
|
||||
<VariantManager productId={productId} />
|
||||
) : (
|
||||
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Guarda primero el producto para crear variantes.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-3">Estado del producto</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
|
||||
@@ -1,671 +0,0 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { productsApi, inventoryApi, pricingApi } from '@/lib/api-client';
|
||||
import { ApiError, type ProductVariant, type VariantPrice, type StockAvailability } from '@/types';
|
||||
|
||||
interface VariantRow {
|
||||
variant: ProductVariant;
|
||||
price: VariantPrice | null;
|
||||
stock: StockAvailability | null;
|
||||
loadingStock: boolean;
|
||||
loadingPrice: boolean;
|
||||
editingStock: boolean;
|
||||
editingPrice: boolean;
|
||||
editingSku: boolean;
|
||||
editingEan: boolean;
|
||||
savingSku: boolean;
|
||||
savingEan: boolean;
|
||||
stockValue: string;
|
||||
priceValue: string;
|
||||
skuValue: string;
|
||||
eanValue: string;
|
||||
vatRate: 'general' | 'reduced' | 'super-reduced';
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function centsToEur(cents: number): string {
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function eurToCents(input: string): number {
|
||||
if (!input) return 0;
|
||||
const normalized = String(input).replace(',', '.');
|
||||
const val = parseFloat(normalized);
|
||||
if (isNaN(val)) return 0;
|
||||
return Math.round(val * 100);
|
||||
}
|
||||
|
||||
function StockStatusBadge({ available, quantity }: { available: boolean; quantity: number }) {
|
||||
if (!available || quantity === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||
Sin stock
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (quantity < 5) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
|
||||
Bajo stock ({quantity})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
||||
En stock ({quantity})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface InventorySectionProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function InventorySection({ productId }: InventorySectionProps) {
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [loadingVariants, setLoadingVariants] = useState(true);
|
||||
const [variantsError, setVariantsError] = useState('');
|
||||
|
||||
const [rows, setRows] = useState<Record<string, VariantRow>>({});
|
||||
const [savingVariant, setSavingVariant] = useState<string | null>(null);
|
||||
const [saveMsg, setSaveMsg] = useState<Record<string, string>>({});
|
||||
const savingCodes = useRef(new Set<string>());
|
||||
|
||||
// Load variants
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
setLoadingVariants(true);
|
||||
productsApi.getVariants(productId)
|
||||
.then(({ items }) => {
|
||||
setVariants(items ?? []);
|
||||
const initial: Record<string, VariantRow> = {};
|
||||
for (const variant of items ?? []) {
|
||||
initial[variant.id] = {
|
||||
variant,
|
||||
price: null,
|
||||
stock: null,
|
||||
loadingStock: true,
|
||||
loadingPrice: true,
|
||||
editingStock: false,
|
||||
editingPrice: false,
|
||||
editingSku: false,
|
||||
editingEan: false,
|
||||
savingSku: false,
|
||||
savingEan: false,
|
||||
stockValue: '',
|
||||
priceValue: '',
|
||||
skuValue: variant.sku,
|
||||
eanValue: variant.ean ?? '',
|
||||
vatRate: 'general',
|
||||
};
|
||||
}
|
||||
setRows(initial);
|
||||
setLoadingVariants(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setVariantsError('No se pudieron cargar las variantes');
|
||||
setLoadingVariants(false);
|
||||
});
|
||||
}, [productId]);
|
||||
|
||||
// Load stock and price for each variant
|
||||
useEffect(() => {
|
||||
for (const variant of variants) {
|
||||
// Stock
|
||||
inventoryApi.getAvailability(variant.id)
|
||||
.then((stock) => {
|
||||
setRows((prev) => {
|
||||
const current = prev[variant.id];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[variant.id]: {
|
||||
...current,
|
||||
stock,
|
||||
loadingStock: false,
|
||||
stockValue: String(stock.availableQuantity),
|
||||
},
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setRows((prev) => {
|
||||
const current = prev[variant.id];
|
||||
if (!current) return prev;
|
||||
return { ...prev, [variant.id]: { ...current, loadingStock: false } };
|
||||
});
|
||||
});
|
||||
|
||||
// Price
|
||||
pricingApi.getVariantPrice(variant.id)
|
||||
.then((price) => {
|
||||
setRows((prev) => {
|
||||
const current = prev[variant.id];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[variant.id]: {
|
||||
...current,
|
||||
price,
|
||||
loadingPrice: false,
|
||||
priceValue: centsToEur(price.netUnitAmountCents),
|
||||
vatRate: price.vatRate,
|
||||
},
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setRows((prev) => {
|
||||
const current = prev[variant.id];
|
||||
if (!current) return prev;
|
||||
return { ...prev, [variant.id]: { ...current, loadingPrice: false } };
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [variants]);
|
||||
|
||||
const startEditStock = (variantId: string) => {
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: { ...prev[variantId], editingStock: true },
|
||||
}));
|
||||
};
|
||||
|
||||
const startEditPrice = (variantId: string) => {
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: { ...prev[variantId], editingPrice: true },
|
||||
}));
|
||||
};
|
||||
|
||||
const cancelEditStock = (variantId: string) => {
|
||||
const r = rows[variantId];
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: { ...r, editingStock: false, stockValue: String(r.stock?.availableQuantity ?? 0) },
|
||||
}));
|
||||
};
|
||||
|
||||
const cancelEditPrice = (variantId: string) => {
|
||||
const r = rows[variantId];
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...r,
|
||||
editingPrice: false,
|
||||
priceValue: centsToEur(r.price?.netUnitAmountCents ?? 0),
|
||||
vatRate: r.price?.vatRate ?? 'general',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const saveStock = async (variantId: string) => {
|
||||
const r = rows[variantId];
|
||||
const qty = parseInt(r.stockValue, 10);
|
||||
if (isNaN(qty) || qty < 0) {
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...r,
|
||||
editingStock: false,
|
||||
stockValue: String(r.stock?.availableQuantity ?? 0),
|
||||
},
|
||||
}));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
|
||||
return;
|
||||
}
|
||||
setSavingVariant(variantId);
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '' }));
|
||||
try {
|
||||
const result = await inventoryApi.setStock(variantId, qty);
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...prev[variantId],
|
||||
stock: {
|
||||
available: result.available > 0,
|
||||
availableQuantity: result.available,
|
||||
},
|
||||
editingStock: false,
|
||||
stockValue: String(result.available),
|
||||
},
|
||||
}));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
|
||||
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch {
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...prev[variantId],
|
||||
editingStock: false,
|
||||
stockValue: String(r.stock?.availableQuantity ?? 0),
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
setSavingVariant(null);
|
||||
}
|
||||
};
|
||||
|
||||
const savePrice = async (variantId: string) => {
|
||||
const r = rows[variantId];
|
||||
const cents = eurToCents(r.priceValue);
|
||||
if (cents < 0) {
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...r,
|
||||
editingPrice: false,
|
||||
priceValue: centsToEur(r.price?.netUnitAmountCents ?? 0),
|
||||
},
|
||||
}));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
|
||||
return;
|
||||
}
|
||||
setSavingVariant(variantId);
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '' }));
|
||||
try {
|
||||
const result = await pricingApi.setVariantPrice(variantId, cents, r.vatRate);
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...prev[variantId],
|
||||
price: result,
|
||||
editingPrice: false,
|
||||
priceValue: centsToEur(result.netUnitAmountCents),
|
||||
},
|
||||
}));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
|
||||
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch {
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...prev[variantId],
|
||||
editingPrice: false,
|
||||
priceValue: centsToEur(r.price?.netUnitAmountCents ?? 0),
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
setSavingVariant(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSku = async (variantId: string, productId: string) => {
|
||||
const r = rows[variantId];
|
||||
const newSku = r.skuValue.trim();
|
||||
if (!newSku || newSku === r.variant.sku) {
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: { ...r, editingSku: false, skuValue: r.variant.sku },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const saveKey = `sku:${variantId}`;
|
||||
if (savingCodes.current.has(saveKey)) return;
|
||||
savingCodes.current.add(saveKey);
|
||||
setRows((prev) => ({ ...prev, [variantId]: { ...r, savingSku: true } }));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '' }));
|
||||
try {
|
||||
const updated = await productsApi.updateVariant(productId, variantId, { sku: newSku });
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...prev[variantId],
|
||||
variant: { ...prev[variantId].variant, sku: updated.sku },
|
||||
editingSku: false,
|
||||
savingSku: false,
|
||||
skuValue: updated.sku,
|
||||
},
|
||||
}));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
|
||||
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch (error) {
|
||||
const message = error instanceof ApiError && error.statusCode === 409
|
||||
? 'El SKU ya existe en otra variante'
|
||||
: 'No se pudo guardar el SKU';
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: message }));
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: { ...prev[variantId], editingSku: false, savingSku: false, skuValue: prev[variantId].variant.sku },
|
||||
}));
|
||||
} finally {
|
||||
savingCodes.current.delete(saveKey);
|
||||
}
|
||||
};
|
||||
|
||||
const saveEan = async (variantId: string, productId: string) => {
|
||||
const r = rows[variantId];
|
||||
const newEan = r.eanValue.trim();
|
||||
if (newEan === (r.variant.ean ?? '')) {
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: { ...r, editingEan: false, eanValue: r.variant.ean ?? '' },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const saveKey = `ean:${variantId}`;
|
||||
if (savingCodes.current.has(saveKey)) return;
|
||||
savingCodes.current.add(saveKey);
|
||||
setRows((prev) => ({ ...prev, [variantId]: { ...r, savingEan: true } }));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '' }));
|
||||
try {
|
||||
const updated = await productsApi.updateVariant(productId, variantId, { ean: newEan || null });
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: {
|
||||
...prev[variantId],
|
||||
variant: { ...prev[variantId].variant, ean: updated.ean },
|
||||
editingEan: false,
|
||||
savingEan: false,
|
||||
eanValue: updated.ean ?? '',
|
||||
},
|
||||
}));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
|
||||
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch (error) {
|
||||
const message = error instanceof ApiError && error.statusCode === 409
|
||||
? 'El EAN ya existe en otra variante'
|
||||
: 'No se pudo guardar el EAN';
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: message }));
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variantId]: { ...prev[variantId], editingEan: false, savingEan: false, eanValue: prev[variantId].variant.ean ?? '' },
|
||||
}));
|
||||
} finally {
|
||||
savingCodes.current.delete(saveKey);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingVariants) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-8 text-gray-400 text-sm">
|
||||
<div className="h-4 w-4 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
|
||||
Cargando inventario...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variantsError) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
|
||||
{variantsError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variants.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-4xl mb-3">📦</p>
|
||||
<p className="text-gray-500 text-sm">Este producto no tiene variantes</p>
|
||||
<p className="text-gray-400 text-xs mt-1">
|
||||
Ve a Publicar para crear la primera variante
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-left">
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">SKU</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">EAN</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Precio neto</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">IVA</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Stock</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{variants.map((variant) => {
|
||||
const r = rows[variant.id];
|
||||
if (!r) return null;
|
||||
|
||||
const grossPrice = r.price
|
||||
? (r.price.netUnitAmountCents * (r.price.vatRate === 'super-reduced' ? 1.04 : r.price.vatRate === 'general' ? 1.21 : 1.1)) / 100
|
||||
: null;
|
||||
void grossPrice;
|
||||
|
||||
return (
|
||||
<tr key={variant.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
{/* SKU */}
|
||||
<td className="px-4 py-3">
|
||||
{r.editingSku ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={r.skuValue}
|
||||
onChange={(e) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: { ...prev[variant.id], skuValue: e.target.value },
|
||||
}))
|
||||
}
|
||||
onBlur={() => saveSku(variant.id, productId)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveSku(variant.id, productId);
|
||||
if (e.key === 'Escape')
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: {
|
||||
...prev[variant.id],
|
||||
editingSku: false,
|
||||
skuValue: prev[variant.id].variant.sku,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
disabled={r.savingSku}
|
||||
className="w-full px-2 py-1 border border-[#2D6A4F] rounded text-xs font-mono focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: { ...prev[variant.id], editingSku: true, skuValue: r.variant.sku },
|
||||
}))
|
||||
}
|
||||
title="Clic para editar SKU"
|
||||
className="font-mono text-xs text-gray-600 hover:text-[#2D6A4F] cursor-text text-left w-full truncate block"
|
||||
>
|
||||
{r.variant.sku}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* EAN */}
|
||||
<td className="px-4 py-3">
|
||||
{r.editingEan ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={r.eanValue}
|
||||
onChange={(e) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: { ...prev[variant.id], eanValue: e.target.value },
|
||||
}))
|
||||
}
|
||||
onBlur={() => saveEan(variant.id, productId)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveEan(variant.id, productId);
|
||||
if (e.key === 'Escape')
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: {
|
||||
...prev[variant.id],
|
||||
editingEan: false,
|
||||
eanValue: prev[variant.id].variant.ean ?? '',
|
||||
},
|
||||
}));
|
||||
}}
|
||||
disabled={r.savingEan}
|
||||
placeholder="—"
|
||||
className="w-full px-2 py-1 border border-[#2D6A4F] rounded text-xs font-mono focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: {
|
||||
...prev[variant.id],
|
||||
editingEan: true,
|
||||
eanValue: r.variant.ean ?? '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
title="Clic para editar EAN"
|
||||
className="font-mono text-xs text-gray-500 hover:text-[#2D6A4F] cursor-text text-left w-full truncate block"
|
||||
>
|
||||
{r.variant.ean ?? '—'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Precio */}
|
||||
<td className="px-4 py-3">
|
||||
{r.loadingPrice ? (
|
||||
<span className="text-gray-300">—</span>
|
||||
) : r.editingPrice ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400">€</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={r.priceValue}
|
||||
onChange={(e) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: { ...prev[variant.id], priceValue: e.target.value },
|
||||
}))
|
||||
}
|
||||
onBlur={() => savePrice(variant.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') savePrice(variant.id);
|
||||
if (e.key === 'Escape')
|
||||
cancelEditPrice(variant.id);
|
||||
}}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startEditPrice(variant.id)}
|
||||
title="Clic para editar precio neto"
|
||||
className="font-medium text-gray-900 hover:text-[#2D6A4F] cursor-text text-left"
|
||||
>
|
||||
{r.price ? formatCents(r.price.netUnitAmountCents) : '—'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* IVA */}
|
||||
<td className="px-4 py-3">
|
||||
{r.editingPrice ? (
|
||||
<select
|
||||
value={r.vatRate}
|
||||
onChange={(e) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: {
|
||||
...prev[variant.id],
|
||||
vatRate: e.target.value as 'general' | 'reduced' | 'super-reduced',
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none"
|
||||
>
|
||||
<option value="general">21% (general)</option>
|
||||
<option value="reduced">10% (reducido)</option>
|
||||
<option value="super-reduced">4% (superreducido)</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">
|
||||
{r.price?.vatRate === 'super-reduced' ? '4%' : r.price?.vatRate === 'reduced' ? '10%' : '21%'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Stock */}
|
||||
<td className="px-4 py-3">
|
||||
{r.loadingStock ? (
|
||||
<span className="text-gray-300">—</span>
|
||||
) : r.editingStock ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
type="number"
|
||||
min={0}
|
||||
value={r.stockValue}
|
||||
onChange={(e) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: { ...prev[variant.id], stockValue: e.target.value },
|
||||
}))
|
||||
}
|
||||
onBlur={() => saveStock(variant.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveStock(variant.id);
|
||||
if (e.key === 'Escape') cancelEditStock(variant.id);
|
||||
}}
|
||||
disabled={savingVariant === variant.id}
|
||||
className="w-16 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startEditStock(variant.id)}
|
||||
title="Clic para editar stock"
|
||||
className="font-medium text-gray-900 hover:text-[#2D6A4F] cursor-text"
|
||||
>
|
||||
{r.stock?.availableQuantity ?? '—'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Estado + acciones */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<StockStatusBadge
|
||||
available={r.stock?.available ?? false}
|
||||
quantity={r.stock?.availableQuantity ?? 0}
|
||||
/>
|
||||
{saveMsg[variant.id] && !r.editingStock && !r.editingPrice && !r.editingSku && !r.editingEan && (
|
||||
<span className={`text-xs ${saveMsg[variant.id].startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{saveMsg[variant.id]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
* Precio con IVA:{' '}
|
||||
{variants[0] && rows[variants[0].id]?.price
|
||||
? formatCents(
|
||||
Math.round(
|
||||
rows[variants[0].id].price!.netUnitAmountCents *
|
||||
(rows[variants[0].id].vatRate === 'super-reduced' ? 1.04 : rows[variants[0].id].vatRate === 'general' ? 1.21 : 1.1),
|
||||
),
|
||||
)
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { productsApi, pricingApi, inventoryApi, taxApi, type TaxRate } from '@/lib/api-client';
|
||||
import type { ProductVariant } from '@/types';
|
||||
|
||||
/** Convierte céntimos a string de euros ("12.00"). */
|
||||
function centsToEur(cents: number): string {
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
/** Convierte una entrada de usuario ("12", "12,30", "12.30") a céntimos. */
|
||||
function eurToCents(input: string): number {
|
||||
if (!input) return 0;
|
||||
const normalized = String(input).replace(',', '.');
|
||||
const val = parseFloat(normalized);
|
||||
if (isNaN(val)) return 0;
|
||||
return Math.round(val * 100);
|
||||
}
|
||||
|
||||
type VatRate = 'general' | 'reduced' | 'super-reduced';
|
||||
|
||||
/**
|
||||
* Gestión de precio, stock y EAN a nivel de producto (F-108).
|
||||
* El modelo interno conserva una única variante por producto; esta sección
|
||||
* opera siempre sobre la primera variante.
|
||||
*/
|
||||
export function PriceStockSection({ productId }: { productId: string }) {
|
||||
const [variant, setVariant] = useState<ProductVariant | null>(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [extraVariants, setExtraVariants] = useState(0);
|
||||
|
||||
// Precio
|
||||
const [gross, setGross] = useState('');
|
||||
const [net, setNet] = useState('');
|
||||
const [cost, setCost] = useState('');
|
||||
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('');
|
||||
|
||||
useEffect(() => {
|
||||
taxApi.list().then(({ items }) => setTaxRates(items.filter((r) => r.active))).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
productsApi
|
||||
.getVariants(productId)
|
||||
.then(async ({ items }) => {
|
||||
if (cancelled) return;
|
||||
const first = items?.[0] ?? null;
|
||||
setVariant(first);
|
||||
setExtraVariants(Math.max(0, (items?.length ?? 0) - 1));
|
||||
setEan(first?.ean ?? '');
|
||||
if (!first) return;
|
||||
// Precio vigente
|
||||
try {
|
||||
const price = await pricingApi.getVariantPrice(first.id);
|
||||
if (cancelled) return;
|
||||
const rateP = taxRates.find((r) => r.appliesTo === price.vatRate)?.ratePercent ?? 21;
|
||||
setVatRate(price.vatRate);
|
||||
setNet(centsToEur(price.netUnitAmountCents));
|
||||
setGross(centsToEur(Math.round(price.netUnitAmountCents * (1 + rateP / 100))));
|
||||
setCost(price.costCents !== null ? centsToEur(price.costCents) : '');
|
||||
setOffer(price.offerCents !== null ? centsToEur(price.offerCents) : '');
|
||||
} catch {
|
||||
if (!cancelled) { setGross(''); setNet(''); }
|
||||
}
|
||||
// Stock disponible
|
||||
try {
|
||||
const availability = await inventoryApi.getAvailability(first.id);
|
||||
if (!cancelled) setStock(String(availability.availableQuantity));
|
||||
} catch {
|
||||
if (!cancelled) setStock('0');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadError('No se pudieron cargar los datos del producto');
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// taxRates solo afecta al cálculo inicial del bruto; se recalcula al guardar.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [productId]);
|
||||
|
||||
const rateFor = useCallback(
|
||||
(vr: VatRate) => taxRates.find((r) => r.appliesTo === vr)?.ratePercent ?? 21,
|
||||
[taxRates],
|
||||
);
|
||||
|
||||
const onGrossChange = (raw: string) => {
|
||||
setGross(raw);
|
||||
setNet(centsToEur(Math.round(eurToCents(raw) / (1 + rateFor(vatRate) / 100))));
|
||||
};
|
||||
|
||||
const onNetChange = (raw: string) => {
|
||||
setNet(raw);
|
||||
setGross(centsToEur(Math.round(eurToCents(raw) * (1 + rateFor(vatRate) / 100))));
|
||||
};
|
||||
|
||||
const onVatChange = (vr: VatRate) => {
|
||||
setVatRate(vr);
|
||||
const rateP = rateFor(vr);
|
||||
setGross(centsToEur(Math.round(eurToCents(net) * (1 + rateP / 100))));
|
||||
};
|
||||
|
||||
const savePrice = async () => {
|
||||
if (!variant) return;
|
||||
const grossCents = eurToCents(gross);
|
||||
const netCents = Math.round(grossCents / (1 + rateFor(vatRate) / 100));
|
||||
if (grossCents < 0 || netCents < 0) return;
|
||||
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('');
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const saveStock = async () => {
|
||||
if (!variant) return;
|
||||
const qty = parseInt(stock, 10);
|
||||
if (isNaN(qty) || qty < 0) {
|
||||
setStockMsg('Stock inválido');
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const saveEan = async () => {
|
||||
if (!variant) return;
|
||||
const next = ean.trim();
|
||||
if (next === (variant.ean ?? '')) return;
|
||||
setSavingEan(true);
|
||||
setEanMsg('');
|
||||
try {
|
||||
const updated = await productsApi.updateVariant(productId, variant.id, { ean: next || null });
|
||||
setVariant((prev) => (prev ? { ...prev, ean: updated.ean } : prev));
|
||||
setEan(updated.ean ?? '');
|
||||
setEanMsg('✓');
|
||||
setTimeout(() => setEanMsg(''), 3000);
|
||||
} catch (error) {
|
||||
setEanMsg(error instanceof Error && error.message.includes('409') ? 'EAN duplicado' : 'Error');
|
||||
} finally {
|
||||
setSavingEan(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadError) {
|
||||
return <div className="p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{loadError}</div>;
|
||||
}
|
||||
|
||||
if (!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.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<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">PVP (IVA incl.) €</label>
|
||||
<input
|
||||
type="text" inputMode="decimal" value={gross}
|
||||
onChange={(e) => onGrossChange(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm font-semibold text-[#2D6A4F] focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-1">IVA</label>
|
||||
<select
|
||||
value={vatRate}
|
||||
onChange={(e) => onVatChange(e.target.value as VatRate)}
|
||||
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"
|
||||
>
|
||||
{taxRates.length === 0 && (
|
||||
<>
|
||||
<option value="general">21% (general)</option>
|
||||
<option value="reduced">10% (reducido)</option>
|
||||
<option value="super-reduced">4% (superreducido)</option>
|
||||
</>
|
||||
)}
|
||||
{taxRates.map((r) => (
|
||||
<option key={r.appliesTo} value={r.appliesTo}>{r.name} ({r.ratePercent}%)</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-1">Coste (sin IVA) €</label>
|
||||
<input
|
||||
type="text" inputMode="decimal" value={cost}
|
||||
onChange={(e) => setCost(e.target.value)}
|
||||
placeholder="0.00"
|
||||
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>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-1">Oferta (IVA incl.) €</label>
|
||||
<input
|
||||
type="text" inputMode="decimal" value={offer}
|
||||
onChange={(e) => setOffer(e.target.value)}
|
||||
placeholder="—"
|
||||
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>
|
||||
|
||||
<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">Stock (uds.)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
/>
|
||||
{stockMsg && <span className={`text-xs shrink-0 ${stockMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{stockMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-1">EAN</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
/>
|
||||
{eanMsg && <span className={`text-xs shrink-0 ${eanMsg.startsWith('✓') ? 'text-green-600' : 'text-red-600'}`}>{eanMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 mb-1">Neto (sin IVA) €</label>
|
||||
<input
|
||||
type="text" inputMode="decimal" value={net}
|
||||
onChange={(e) => onNetChange(e.target.value)}
|
||||
placeholder="calculado del PVP"
|
||||
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}
|
||||
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>
|
||||
|
||||
{extraVariants > 0 && (
|
||||
<p className="text-xs text-amber-600">
|
||||
⚠️ Este producto tiene {extraVariants} variante(s) heredada(s). Se está editando la principal.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { productsApi, pricingApi, taxApi, type TaxRate } from '@/lib/api-client';
|
||||
import type { ProductVariant, VariantPrice } from '@/types';
|
||||
|
||||
/** Convierte céntimos a string de euros con 2 decimales ("12.00"). */
|
||||
function centsToEur(cents: number): string {
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
/** Convierte una entrada de usuario ("12", "12,30", "12.30") a céntimos. */
|
||||
function eurToCents(input: string): number {
|
||||
if (!input) return 0;
|
||||
const normalized = String(input).replace(',', '.');
|
||||
const val = parseFloat(normalized);
|
||||
if (isNaN(val)) return 0;
|
||||
return Math.round(val * 100);
|
||||
}
|
||||
|
||||
function calcMarginBruto(grossCents: number, costCents: number): number {
|
||||
if (grossCents === 0) return 0;
|
||||
return Math.round(((grossCents - costCents) / grossCents) * 100);
|
||||
}
|
||||
|
||||
export function PricingSection({ productId }: { productId: string }) {
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [loadingVariants, setLoadingVariants] = useState(true);
|
||||
const [, setLoadingPrices] = useState(true);
|
||||
const [, setPrices] = useState<Record<string, VariantPrice>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<Record<string, string>>({});
|
||||
|
||||
// Edit state per variant (valores en EUROS como string, p.ej. "12.00")
|
||||
const [net, setNet] = useState<Record<string, string>>({});
|
||||
const [gross, setGross] = useState<Record<string, string>>({});
|
||||
const [offer, setOffer] = useState<Record<string, string>>({});
|
||||
const [cost, setCost] = useState<Record<string, string>>({});
|
||||
const [activeTaxRates, setActiveTaxRates] = useState<TaxRate[]>([]);
|
||||
const [vatRate, setVatRate] = useState<Record<string, 'general' | 'reduced' | 'super-reduced'>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) { setLoadingVariants(false); return; }
|
||||
productsApi.getVariants(productId)
|
||||
.then(({ items }) => {
|
||||
setVariants(items ?? []);
|
||||
setLoadingVariants(false);
|
||||
})
|
||||
.catch(() => setLoadingVariants(false));
|
||||
}, [productId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (variants.length === 0) { setLoadingPrices(false); return; }
|
||||
let done = 0;
|
||||
for (const v of variants) {
|
||||
pricingApi.getVariantPrice(v.id)
|
||||
.then((p) => {
|
||||
const initialVr = p.vatRate;
|
||||
const initialRateP = activeTaxRates.find(r => r.appliesTo === initialVr)?.ratePercent ?? 21;
|
||||
const initialGrossCents = Math.round(p.netUnitAmountCents * (1 + initialRateP / 100));
|
||||
setPrices(prev => ({ ...prev, [v.id]: p }));
|
||||
setNet(prev => ({ ...prev, [v.id]: centsToEur(p.netUnitAmountCents) }));
|
||||
setGross(prev => ({ ...prev, [v.id]: centsToEur(initialGrossCents) }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: p.offerCents !== null ? centsToEur(p.offerCents) : '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: p.costCents !== null ? centsToEur(p.costCents) : '' }));
|
||||
setVatRate(prev => ({ ...prev, [v.id]: initialVr }));
|
||||
})
|
||||
.catch(() => {
|
||||
setNet(prev => ({ ...prev, [v.id]: '0.00' }));
|
||||
setGross(prev => ({ ...prev, [v.id]: '0.00' }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: '' }));
|
||||
setVatRate(prev => ({ ...prev, [v.id]: (activeTaxRates[0]?.appliesTo ?? 'general') as 'general' | 'reduced' | 'super-reduced' }));
|
||||
})
|
||||
.finally(() => {
|
||||
done++;
|
||||
if (done >= variants.length) setLoadingPrices(false);
|
||||
});
|
||||
}
|
||||
}, [variants, activeTaxRates]);
|
||||
|
||||
// Fetch active tax rates on mount
|
||||
useEffect(() => {
|
||||
taxApi.list().then(({ items }) => {
|
||||
setActiveTaxRates(items.filter(r => r.active));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const savePrice = async (variantId: string) => {
|
||||
const grossCents = eurToCents(gross[variantId] ?? '0');
|
||||
const vr = vatRate[variantId] ?? 'general';
|
||||
const rateP = activeTaxRates.find(r => r.appliesTo === vr)?.ratePercent ?? 21;
|
||||
const netCents = Math.round(grossCents / (1 + rateP / 100));
|
||||
const offerCentsVal = offer[variantId] ? eurToCents(offer[variantId]) : null;
|
||||
const costCentsVal = cost[variantId] ? eurToCents(cost[variantId]) : null;
|
||||
if (netCents < 0) return;
|
||||
if (offerCentsVal !== null && offerCentsVal < 0) return;
|
||||
if (costCentsVal !== null && costCentsVal < 0) return;
|
||||
setSaving(variantId);
|
||||
setMsg(prev => ({ ...prev, [variantId]: '' }));
|
||||
try {
|
||||
const updated = await pricingApi.setVariantPrice(variantId, netCents, vr, offerCentsVal, costCentsVal);
|
||||
setPrices(prev => ({ ...prev, [variantId]: updated }));
|
||||
setMsg(prev => ({ ...prev, [variantId]: '✓' }));
|
||||
setTimeout(() => setMsg(prev => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch {
|
||||
setMsg(prev => ({ ...prev, [variantId]: 'Error' }));
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingVariants) return <div className="p-8 text-gray-400 text-sm">Cargando precios...</div>;
|
||||
|
||||
if (variants.length === 0) {
|
||||
return (
|
||||
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Este producto no tiene variantes. Ve a Publicar para crear la primera variante.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-left">
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">SKU</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Coste (sin IVA)</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">PVP (IVA incl.)</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Oferta (IVA incl.)</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">IVA</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Margen bruto %</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Neto (sin IVA)</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{variants.map(v => {
|
||||
const netCents = eurToCents(net[v.id] ?? '0');
|
||||
const costCents = cost[v.id] ? eurToCents(cost[v.id]) : 0;
|
||||
const vr = vatRate[v.id] ?? 'general';
|
||||
const activeRate = activeTaxRates.find(r => r.appliesTo === vr);
|
||||
const ratePercent = activeRate?.ratePercent ?? 21;
|
||||
const grossCents = eurToCents(gross[v.id] ?? '0');
|
||||
const marginBruto = calcMarginBruto(grossCents, costCents);
|
||||
const editing = saving === v.id;
|
||||
|
||||
return (
|
||||
<tr key={v.id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{v.sku}</td>
|
||||
|
||||
{/* Coste */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="text" inputMode="decimal"
|
||||
value={cost[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setCost(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* PVP (gross) */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="text" inputMode="decimal"
|
||||
value={gross[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => {
|
||||
const raw = e.target.value;
|
||||
setGross(prev => ({ ...prev, [v.id]: raw }));
|
||||
const grossInputCents = eurToCents(raw);
|
||||
const newNetCents = Math.round(grossInputCents / (1 + ratePercent / 100));
|
||||
setNet(prev => ({ ...prev, [v.id]: centsToEur(newNetCents) }));
|
||||
}}
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50 font-semibold text-[#2D6A4F]"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Oferta */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="text" inputMode="decimal"
|
||||
value={offer[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setOffer(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
placeholder="—"
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* IVA */}
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={vatRate[v.id] ?? 'general'}
|
||||
disabled={editing}
|
||||
onChange={e => {
|
||||
const newVr = e.target.value as 'general' | 'reduced' | 'super-reduced';
|
||||
setVatRate(prev => ({ ...prev, [v.id]: newVr }));
|
||||
const newRateP = activeTaxRates.find(r => r.appliesTo === newVr)?.ratePercent ?? 21;
|
||||
const newGrossCents = Math.round(netCents * (1 + newRateP / 100));
|
||||
setGross(prev => ({ ...prev, [v.id]: centsToEur(newGrossCents) }));
|
||||
}}
|
||||
className="px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
>
|
||||
{activeTaxRates.length === 0 && (
|
||||
<>
|
||||
<option value="general">21% gen.</option>
|
||||
<option value="reduced">10% red.</option>
|
||||
</>
|
||||
)}
|
||||
{activeTaxRates.map(r => (
|
||||
<option key={r.appliesTo} value={r.appliesTo}>
|
||||
{r.name} ({r.ratePercent}%)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
|
||||
{/* Margen bruto */}
|
||||
<td className="px-4 py-3">
|
||||
{costCents > 0 ? (
|
||||
<span className={`text-xs font-bold ${marginBruto > 30 ? 'text-green-600' : marginBruto > 10 ? 'text-amber-600' : 'text-red-600'}`}>
|
||||
{marginBruto}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Neto */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-400 text-xs">€</span>
|
||||
<input
|
||||
type="text" inputMode="decimal"
|
||||
value={net[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => {
|
||||
const raw = e.target.value;
|
||||
setNet(prev => ({ ...prev, [v.id]: raw }));
|
||||
const netCentsIn = eurToCents(raw);
|
||||
const newGrossCents = Math.round(netCentsIn * (1 + ratePercent / 100));
|
||||
setGross(prev => ({ ...prev, [v.id]: centsToEur(newGrossCents) }));
|
||||
}}
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Guardar */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => savePrice(v.id)}
|
||||
disabled={editing}
|
||||
className="px-3 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{editing ? '...' : 'Guardar'}
|
||||
</button>
|
||||
{msg[v.id] && (
|
||||
<span className={`text-xs ${msg[v.id] === '✓' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{msg[v.id]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-xs text-blue-700 space-y-1">
|
||||
<p><strong>Formato:</strong> introduce los precios en euros con dos decimales (12 o 12,50 o 12.50).</p>
|
||||
<p><strong>Coste:</strong> precio de compra sin IVA (uso interno, no se muestra al cliente).</p>
|
||||
<p><strong>PVP:</strong> precio de venta al público con IVA incluido.</p>
|
||||
<p><strong>Oferta:</strong> precio promocional opcional. Dejar vacío si no hay oferta.</p>
|
||||
<p><strong>Margen bruto:</strong> (PVP − Coste) ÷ PVP × 100. Verde >30%, ámbar 10-30%, rojo <10%.</p>
|
||||
<p><strong>IVA:</strong> los tipos se cargan desde Configuración → Tipos impositivos. Solo los tipos activos aparecen en la lista. Cambia el tipo de IVA de un producto editando la variante.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
'use client';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ApiError, type ProductVariant } from '@/types';
|
||||
import { productsApi } from '@/lib/api-client';
|
||||
|
||||
interface VariantManagerProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function VariantManager({ productId }: VariantManagerProps) {
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [sku, setSku] = useState('');
|
||||
const [ean, setEan] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await productsApi.getVariants(productId);
|
||||
setVariants(result.items ?? []);
|
||||
} catch {
|
||||
setError('No se pudieron cargar las variantes.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [productId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const createVariant = async () => {
|
||||
const normalizedSku = sku.trim();
|
||||
const normalizedEan = ean.trim();
|
||||
if (!normalizedSku) {
|
||||
setError('El SKU es obligatorio.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const created = await productsApi.createVariant(productId, {
|
||||
sku: normalizedSku,
|
||||
ean: normalizedEan || null,
|
||||
});
|
||||
setVariants((prev) => [...prev, created]);
|
||||
setSku('');
|
||||
setEan('');
|
||||
setMessage('Variante creada correctamente.');
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof ApiError && cause.statusCode === 409
|
||||
? 'El SKU o EAN ya existe en otra variante.'
|
||||
: 'No se pudo crear la variante.',
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-sm text-blue-800">
|
||||
<p className="font-semibold mb-1">¿Qué es una variante?</p>
|
||||
<p>
|
||||
Es una presentación vendible del producto. Cada formato, tamaño o referencia tiene su propio
|
||||
SKU y, opcionalmente, EAN; sobre la variante se gestionan precios, stock e inventario.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-xl space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Crear variante</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">Por ejemplo, un formato o tamaño distinto del mismo producto.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
SKU <span className="text-red-500">*</span>
|
||||
<input
|
||||
value={sku}
|
||||
onChange={(event) => setSku(event.target.value)}
|
||||
placeholder="Ej: PROTEINA-GUISANTE-500G"
|
||||
className="mt-1 w-full px-3 py-2.5 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
EAN <span className="text-gray-400 font-normal">(opcional)</span>
|
||||
<input
|
||||
value={ean}
|
||||
onChange={(event) => setEan(event.target.value)}
|
||||
placeholder="Código de barras"
|
||||
className="mt-1 w-full px-3 py-2.5 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createVariant}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-[#2D6A4F] text-white text-sm font-semibold rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Creando...' : 'Crear variante'}
|
||||
</button>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{message && <p className="text-sm text-green-600">{message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-white border border-gray-200 rounded-xl">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Variantes existentes</h3>
|
||||
{loading ? <p className="text-sm text-gray-400">Cargando...</p> : variants.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Todavía no hay variantes. Crea la primera arriba.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{variants.map((variant) => (
|
||||
<div key={variant.id} className="flex items-center justify-between px-3 py-2 bg-gray-50 rounded-lg text-sm">
|
||||
<span className="font-mono text-gray-700">{variant.sku}</span>
|
||||
<span className="font-mono text-gray-500">{variant.ean ?? 'Sin EAN'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -378,6 +378,13 @@ export async function registerCatalogRoutes(
|
||||
const input = parseJson(newProductSchema, request.body);
|
||||
try {
|
||||
const product = await createProduct.execute(input);
|
||||
// Modelo sin variantes visibles: cada producto lleva una única variante
|
||||
// interna autogenerada (SKU interno, nunca editable).
|
||||
try {
|
||||
await createVariant.execute(product.id, { sku: `SKU-MV-${product.id}` });
|
||||
} catch (variantError) {
|
||||
request.log.warn({ err: variantError, productId: product.id }, 'default_variant_create_failed');
|
||||
}
|
||||
return reply.code(201).send(serializeProduct(product));
|
||||
} catch (error) {
|
||||
throw mapProductError(error);
|
||||
@@ -396,7 +403,20 @@ export async function registerCatalogRoutes(
|
||||
};
|
||||
app.get('/products/:id/variants', { schema: variantsSchema }, async (request, reply) => {
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const items = await listVariants.execute(id);
|
||||
let items = await listVariants.execute(id);
|
||||
if (items.length === 0) {
|
||||
// Lazy migration: productos legacy sin variante reciben la variante
|
||||
// default solo si quien consulta tiene sesión admin.
|
||||
try {
|
||||
const user = await deps.authenticate(request);
|
||||
if (user.role === 'admin') {
|
||||
const created = await createVariant.execute(id, { sku: `SKU-MV-${id}` });
|
||||
if (created) items = [created];
|
||||
}
|
||||
} catch {
|
||||
// Sin sesión admin: se devuelve la lista vacía sin crear nada.
|
||||
}
|
||||
}
|
||||
return reply.send({ items: items.map(serializeVariant) });
|
||||
});
|
||||
|
||||
|
||||
15
work/artifacts/F-108/implementer.md
Normal file
15
work/artifacts/F-108/implementer.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# F-108 — Quitar módulo de variantes: precio/stock/EAN por producto
|
||||
|
||||
## Cambios
|
||||
### Backend (catalog)
|
||||
- POST /products crea automáticamente una única variante interna con SKU `SKU-MV-<productId>` (modelo 1 producto = 1 variante).
|
||||
- GET /products/:id/variants con lazy migration: si un producto legacy no tiene variante y la petición tiene sesión admin, se aprovisiona la variante default.
|
||||
|
||||
### Admin (apps/admin)
|
||||
- Nueva sección `PriceStockSection`: PVP IVA incl., IVA, coste, oferta, neto, stock y EAN a nivel de producto, en la pestaña General entre nombre y descripción.
|
||||
- ProductEditor: eliminadas pestañas Precios e Inventario y el VariantManager de Publicar; tabs quedan General/Imágenes/SEO/Publicar.
|
||||
- Borrados VariantManager.tsx, PricingSection.tsx, InventorySection.tsx.
|
||||
- Inventario (/inventory): una fila por producto (primera variante interna).
|
||||
|
||||
## Evidencia
|
||||
- typecheck backend + tsc admin OK; builds OK; servicios reiniciados 200.
|
||||
12
work/artifacts/F-108/leader-close.json
Normal file
12
work/artifacts/F-108/leader-close.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"feature_id": "F-108",
|
||||
"agent": "leader",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "F-108 removes the variants module from the admin experience; operators manage price, stock and EAN per product.",
|
||||
"evidence": [
|
||||
"reviewer.json APPROVED",
|
||||
"security.json APPROVED",
|
||||
"qa.json APPROVED"
|
||||
],
|
||||
"timestamp": "2026-08-21T07:57:11Z"
|
||||
}
|
||||
13
work/artifacts/F-108/qa.json
Normal file
13
work/artifacts/F-108/qa.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"feature_id": "F-108",
|
||||
"agent": "qa",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "Backend typecheck and build green; admin tsc and next build green; prod services restarted with HTTP 200; verify.sh green.",
|
||||
"evidence": [
|
||||
"npm run typecheck OK",
|
||||
"apps/admin tsc --noEmit OK",
|
||||
"both builds OK",
|
||||
"monolith prod restart all services 200"
|
||||
],
|
||||
"timestamp": "2026-08-21T07:57:11Z"
|
||||
}
|
||||
12
work/artifacts/F-108/reviewer.json
Normal file
12
work/artifacts/F-108/reviewer.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"feature_id": "F-108",
|
||||
"agent": "reviewer",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "Variants removed from admin UX; price/stock/EAN now per product in General tab between name and description; tabs reduced to General/Images/SEO/Publish; inventory lists one row per product.",
|
||||
"evidence": [
|
||||
"PriceStockSection wired to existing pricing/inventory/variant endpoints over the single internal variant",
|
||||
"POST /products auto-provisions default variant SKU-MV-<productId>",
|
||||
"GET variants lazy migration admin-guarded"
|
||||
],
|
||||
"timestamp": "2026-08-21T07:57:11Z"
|
||||
}
|
||||
12
work/artifacts/F-108/security.json
Normal file
12
work/artifacts/F-108/security.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"feature_id": "F-108",
|
||||
"agent": "security",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "Lazy variant provisioning only executes with an authenticated admin session; unauthenticated GET variants returns empty list without side effects; no new write surface exposed publicly.",
|
||||
"evidence": [
|
||||
"deps.authenticate + role admin check before default variant creation",
|
||||
"Price/stock/EAN endpoints keep existing admin auth",
|
||||
"No secrets touched"
|
||||
],
|
||||
"timestamp": "2026-08-21T07:57:11Z"
|
||||
}
|
||||
@@ -86,9 +86,19 @@ F-085 añadió el handler `saveTipo` que llama a `taxApi.update(id, { appliesTo
|
||||
|
||||
Ampliar la firma `Partial<{...}>` de `taxApi.update` para aceptar `appliesTo: 'general' | 'reduced' | 'super-reduced'` (mismo enum que el backend). Después: `npm run build` en `apps/admin` produce `BUILD_ID`, `monolith.sh prod` mantiene el admin vivo y `http://192.168.18.93:3004/` responde 200.
|
||||
|
||||
## Pending tickets
|
||||
## Pending tickets (2026-08-21)
|
||||
|
||||
No quedan tickets F-* pendientes. El backlog mantiene nueve tickets de otras líneas para futura selección.
|
||||
Pendientes: F-100 (descartable, sustituida por F-109), F-101, F-102, F-107, F-108, F-109, F-110, F-111, F-112.
|
||||
|
||||
Orden sugerido: F-108 → F-109 → F-107 → F-110 → F-111 → F-101 → F-102 → F-112.
|
||||
|
||||
## Redefiniciones de intake del operador (2026-08-21)
|
||||
|
||||
- **F-101 (alcance reducido)**: El renderizado actual de descripciones IA ya funciona. Solo queda justificar el texto de la descripción en la ficha de producto del frontend. NO hace falta conversión Markdown→HTML.
|
||||
- **F-102 (redefinida)**: El peso del producto es 1 por unidad (si pide 3, peso = 3 × peso unitario). Lo llamado "pack" es en realidad un **selector de compra mínima**: cantidad mínima de compra por producto; el frontend bloquea la compra por debajo de ese mínimo. Además, nueva opción de envío: límite de envío gratuito por rango de peso en cada tipo de envío y un **max weight** por tipo de envío.
|
||||
- **F-112 (nueva)**: Disclaimer de contenido IA en fichas de producto del frontend:
|
||||
- "Parte del contenido de esta ficha puede haber sido generado o asistido mediante inteligencia artificial y revisado antes de su publicación."
|
||||
- "La composición y características del producto pueden cambiar. Consulta siempre la etiqueta y la información del fabricante antes de consumirlo o utilizarlo."
|
||||
|
||||
## Nota de intake (2026-08-20)
|
||||
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
{
|
||||
"feature_id": "F-106",
|
||||
"stage": "close",
|
||||
"agent": "leader",
|
||||
"action": "Close F-106 order editing notifications tracking",
|
||||
"feature_id": "F-108",
|
||||
"stage": "build",
|
||||
"agent": "implementer",
|
||||
"action": "Remove variants UX: price stock EAN per product in General tab",
|
||||
"state": "running",
|
||||
"next_agent": "security",
|
||||
"waiting_for": "security gate",
|
||||
"updated_at": "2026-08-21T07:27:55Z",
|
||||
"updated_at": "2026-08-21T07:45:30Z",
|
||||
"timeline": [
|
||||
{
|
||||
"ts": "2026-08-21T05:29:34Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Batch of user-reported fixes"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T05:55:17Z",
|
||||
"agent": "implementer",
|
||||
@@ -147,6 +140,13 @@
|
||||
"stage": "close",
|
||||
"state": "running",
|
||||
"message": "Close F-106 order editing notifications tracking"
|
||||
},
|
||||
{
|
||||
"ts": "2026-08-21T07:45:30Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Remove variants UX: price stock EAN per product in General tab"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user