feat(F-094): completed feature
This commit is contained in:
@@ -6,6 +6,7 @@ import { productsApi, brandsApi, categoriesApi } from '@/lib/api-client';
|
||||
import { ImagesSection } from './sections/ImagesSection';
|
||||
import { InventorySection } from './sections/InventorySection';
|
||||
import { PricingSection } from './sections/PricingSection';
|
||||
import { VariantManager } from './sections/VariantManager';
|
||||
import LexicalEditor from '@/features/cms/components/LexicalEditor';
|
||||
|
||||
interface ProductEditorProps {
|
||||
@@ -408,6 +409,13 @@ export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
{/* ── PUBLISH ── */}
|
||||
{tab === 'publish' && (
|
||||
<section className="space-y-5">
|
||||
{productId ? (
|
||||
<VariantManager productId={productId} />
|
||||
) : (
|
||||
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Guarda primero el producto para crear variantes.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-3">Estado del producto</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
|
||||
@@ -407,7 +407,7 @@ export function InventorySection({ productId }: InventorySectionProps) {
|
||||
<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">
|
||||
Las variantes se crean desde la pestaña Publicar
|
||||
Ve a Publicar para crear la primera variante
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -114,7 +114,7 @@ export function PricingSection({ productId }: { productId: string }) {
|
||||
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. Las variantes se crean desde la pestaña Publicar.
|
||||
⚠️ Este producto no tiene variantes. Ve a Publicar para crear la primera variante.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,8 @@ export const productsApi = {
|
||||
get: (id: string) => api.get<import('@/types').Product>(`/api/products/${id}`),
|
||||
getVariants: (id: string) =>
|
||||
api.get<{ items: import('@/types').ProductVariant[] }>(`/api/products/${id}/variants`),
|
||||
createVariant: (productId: string, data: { sku: string; ean?: string | null }) =>
|
||||
api.post<import('@/types').ProductVariant>(`/api/products/${productId}/variants`, data),
|
||||
create: (data: unknown) => api.post<import('@/types').Product>('/api/products', data),
|
||||
update: (id: string, data: unknown) =>
|
||||
api.patch<import('@/types').Product>(`/api/products/${id}`, data),
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user