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