feat(F-108): completed feature

This commit is contained in:
chattie
2026-08-21 09:57:11 +02:00
parent dca7c3214f
commit 840c839f67
16 changed files with 470 additions and 1155 deletions

View File

@@ -4720,13 +4720,15 @@
"Inventory lists products not variants", "Inventory lists products not variants",
"verify.sh is green" "verify.sh is green"
], ],
"status": "pending", "status": "done",
"created_at": "2026-08-21", "created_at": "2026-08-21",
"gates": { "gates": {
"reviewer": false, "reviewer": true,
"security": false, "security": true,
"qa": false "qa": true,
} "close": true
},
"completed_at": "2026-08-21T07:57:11Z"
}, },
{ {
"id": "F-109", "id": "F-109",
@@ -4819,6 +4821,35 @@
"security": false, "security": false,
"qa": 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
}
} }
] ]
} }

View File

@@ -63,8 +63,12 @@ export default function InventoryPage() {
const variantRows: VariantRow[] = []; const variantRows: VariantRow[] = [];
for (const product of products ?? []) { 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); const { items: variants } = await productsApi.getVariants(product.id);
for (const variant of variants ?? []) { const variant = variants?.[0];
if (!variant) continue;
{
variantRows.push({ variantRows.push({
productId: product.id, productId: product.id,
productName: product.name, productName: product.name,

View File

@@ -4,9 +4,7 @@ import { useRouter } from 'next/navigation';
import type { Product, Brand, Category } from '@/types'; import type { Product, Brand, Category } from '@/types';
import { productsApi, brandsApi, categoriesApi } from '@/lib/api-client'; import { productsApi, brandsApi, categoriesApi } from '@/lib/api-client';
import { ImagesSection } from './sections/ImagesSection'; import { ImagesSection } from './sections/ImagesSection';
import { InventorySection } from './sections/InventorySection'; import { PriceStockSection } from './sections/PriceStockSection';
import { PricingSection } from './sections/PricingSection';
import { VariantManager } from './sections/VariantManager';
import LexicalEditor from '@/features/cms/components/LexicalEditor'; import LexicalEditor from '@/features/cms/components/LexicalEditor';
interface ProductEditorProps { interface ProductEditorProps {
@@ -49,7 +47,7 @@ function hasMeaningfulContent(value: string): boolean {
export function ProductEditor({ productId }: ProductEditorProps) { export function ProductEditor({ productId }: ProductEditorProps) {
const router = useRouter(); const router = useRouter();
const isCreate = !productId; 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 [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [loading, setLoading] = useState(!isCreate); const [loading, setLoading] = useState(!isCreate);
@@ -237,12 +235,12 @@ export function ProductEditor({ productId }: ProductEditorProps) {
{/* Tabs */} {/* Tabs */}
<div className="flex border-b border-gray-200 mb-8 overflow-x-auto"> <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)} <button key={t} onClick={() => setTab(t)}
className={`px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${ 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' 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> </button>
))} ))}
</div> </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> <span className="text-xs text-gray-400 hidden sm:inline">(aparece en la home)</span>
</label> </label>
</div> </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> <div>
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label> <label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
<LexicalEditor <LexicalEditor
@@ -353,32 +359,6 @@ export function ProductEditor({ productId }: ProductEditorProps) {
</section> </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 ── */} {/* ── IMAGES ── */}
{tab === 'images' && ( {tab === 'images' && (
<section> <section>
@@ -433,13 +413,6 @@ export function ProductEditor({ productId }: ProductEditorProps) {
{/* ── PUBLISH ── */} {/* ── PUBLISH ── */}
{tab === 'publish' && ( {tab === 'publish' && (
<section className="space-y-5"> <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> <div>
<label className="block text-sm font-semibold text-gray-900 mb-3">Estado del producto</label> <label className="block text-sm font-semibold text-gray-900 mb-3">Estado del producto</label>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 &gt;30%, ámbar 10-30%, rojo &lt;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>
);
}

View File

@@ -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

View File

@@ -378,6 +378,13 @@ export async function registerCatalogRoutes(
const input = parseJson(newProductSchema, request.body); const input = parseJson(newProductSchema, request.body);
try { try {
const product = await createProduct.execute(input); 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)); return reply.code(201).send(serializeProduct(product));
} catch (error) { } catch (error) {
throw mapProductError(error); throw mapProductError(error);
@@ -396,7 +403,20 @@ export async function registerCatalogRoutes(
}; };
app.get('/products/:id/variants', { schema: variantsSchema }, async (request, reply) => { app.get('/products/:id/variants', { schema: variantsSchema }, async (request, reply) => {
const { id } = parseJson(idParamSchema, request.params); 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) }); return reply.send({ items: items.map(serializeVariant) });
}); });

View 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.

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View File

@@ -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. 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) ## Nota de intake (2026-08-20)

View File

@@ -1,20 +1,13 @@
{ {
"feature_id": "F-106", "feature_id": "F-108",
"stage": "close", "stage": "build",
"agent": "leader", "agent": "implementer",
"action": "Close F-106 order editing notifications tracking", "action": "Remove variants UX: price stock EAN per product in General tab",
"state": "running", "state": "running",
"next_agent": "security", "next_agent": "security",
"waiting_for": "security gate", "waiting_for": "security gate",
"updated_at": "2026-08-21T07:27:55Z", "updated_at": "2026-08-21T07:45:30Z",
"timeline": [ "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", "ts": "2026-08-21T05:55:17Z",
"agent": "implementer", "agent": "implementer",
@@ -147,6 +140,13 @@
"stage": "close", "stage": "close",
"state": "running", "state": "running",
"message": "Close F-106 order editing notifications tracking" "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"
} }
] ]
} }