feat(ADM-018): completed feature
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { AuthUser, Role } from '@/types';
|
||||
import { authApi } from '@/lib/api-client';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load session on mount
|
||||
useEffect(() => {
|
||||
authApi
|
||||
.me()
|
||||
.then((data) => {
|
||||
if ('id' in data) {
|
||||
setUser({ id: data.id, email: data.email, role: data.role as Role });
|
||||
}
|
||||
})
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
try {
|
||||
const data = await authApi.login(email, password);
|
||||
// The backend sets the session cookie via Set-Cookie header.
|
||||
// We also set it client-side for immediate access.
|
||||
setUser({ id: data.id, email: data.email, role: data.role as Role });
|
||||
return { ok: true };
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? (err as { message?: string }).message ?? 'Error de login'
|
||||
: 'Error de login';
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Product, Brand, Category } from '@/types';
|
||||
import { productsApi, brandsApi, categoriesApi } from '@/lib/api-client';
|
||||
import { ImagesSection } from './sections/ImagesSection';
|
||||
import { InventorySection } from './sections/InventorySection';
|
||||
import { PricingSection } from './sections/PricingSection';
|
||||
|
||||
interface ProductEditorProps {
|
||||
productId?: string;
|
||||
}
|
||||
|
||||
const ATTRIBUTE_LABELS: Record<string, string> = {
|
||||
bio: '🌿 Bio',
|
||||
'comercio-justo': '⚖️ Comercio Justo',
|
||||
congelado: '❄️ Congelado',
|
||||
'cruelty-free': '🐰 Cruelty Free',
|
||||
'de-temporada': '🍂 De Temporada',
|
||||
demeter: '🌱 Demeter',
|
||||
'fruta-verdura': '🥕 Fruta y Verdura',
|
||||
keto: '🥑 Keto',
|
||||
kosher: '✡️ Kosher',
|
||||
'low-carb': '🍖 Low Carb',
|
||||
'raw-food': '🥗 Raw Food',
|
||||
'sin-azucar': '🚫 Sin Azúcar',
|
||||
'sin-gluten': '🌾 Sin Gluten',
|
||||
'sin-lactosa': '🥛 Sin Lactosa',
|
||||
vegano: '🌱 Vegano',
|
||||
'zero-waste': '♻️ Zero Waste',
|
||||
};
|
||||
|
||||
const CHANNEL_OPTIONS = [
|
||||
{ value: 'all', label: 'Todos los canales' },
|
||||
{ value: 'online', label: 'Solo online' },
|
||||
{ value: 'offline', label: 'Solo offline' },
|
||||
] as const;
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export function ProductEditor({ productId }: ProductEditorProps) {
|
||||
const router = useRouter();
|
||||
const isCreate = !productId;
|
||||
const [tab, setTab] = useState<'general' | 'pricing' | 'inventory' | 'images' | 'seo' | 'publish'>('general');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(!isCreate);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugManual, setSlugManual] = useState(false);
|
||||
const [desc, setDesc] = useState('');
|
||||
const [brandId, setBrandId] = useState('');
|
||||
const [categoryIds, setCategoryIds] = useState<string[]>([]);
|
||||
const [channels, setChannels] = useState<'online' | 'offline' | 'all'>('all');
|
||||
const [featured, setFeatured] = useState(false);
|
||||
const [attributes, setAttributes] = useState<string[]>([]);
|
||||
const [state, setState] = useState('active');
|
||||
const [seoTitle, setSeoTitle] = useState('');
|
||||
const [seoTitleManual, setSeoTitleManual] = useState(false);
|
||||
const [seoDesc, setSeoDesc] = useState('');
|
||||
const [seoDescManual, setSeoDescManual] = useState(false);
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
brandsApi.list().then(({ items }) => setBrands(items ?? [])).catch(() => {});
|
||||
categoriesApi.list().then((data) => {
|
||||
const tree = (data as { items?: Category[] }).items ?? [];
|
||||
const flat = (cats: Category[]): Category[] => cats.flatMap(c => [c, ...flat(c.children ?? [])]);
|
||||
setCategories(flat(tree));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const snapRef = useRef('');
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
const getSnap = useCallback(() => JSON.stringify({
|
||||
name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc,
|
||||
}), [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) { setLoading(false); return; }
|
||||
productsApi.get(productId).then((p: Product) => {
|
||||
setName(p.name); setSlug(p.slug); setDesc(p.description ?? '');
|
||||
setBrandId(p.brandId ?? ''); setCategoryIds(p.categoryIds ?? []);
|
||||
setChannels((p as any).channels ?? 'all');
|
||||
setFeatured((p as any).featured ?? false);
|
||||
setAttributes((p as any).attributes ?? []);
|
||||
setState(p.state);
|
||||
setSeoTitle((p as any).seoTitle ?? ''); setSeoTitleManual(true);
|
||||
setSeoDesc((p as any).seoDescription ?? ''); setSeoDescManual(true);
|
||||
snapRef.current = getSnap();
|
||||
setLoading(false);
|
||||
}).catch(() => { setError('No se pudo cargar el producto'); setLoading(false); });
|
||||
}, [productId, getSnap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
dirtyRef.current = getSnap() !== snapRef.current;
|
||||
}, [name, slug, desc, brandId, categoryIds, channels, featured, attributes, state, seoTitle, seoDesc, loading, getSnap]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: BeforeUnloadEvent) => { if (dirtyRef.current) { e.preventDefault(); e.returnValue = ''; } };
|
||||
window.addEventListener('beforeunload', h);
|
||||
return () => window.removeEventListener('beforeunload', h);
|
||||
}, []);
|
||||
|
||||
const handleNameChange = (v: string) => {
|
||||
setName(v);
|
||||
if (!slugManual) setSlug(slugify(v));
|
||||
if (!seoTitleManual) setSeoTitle(v);
|
||||
if (!seoDescManual) setSeoDesc(`${v} — Compra online en MercadoDeVida. Productos naturales y ecológicos.`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const payload = {
|
||||
name, slug,
|
||||
description: desc || undefined,
|
||||
brandId: brandId || undefined,
|
||||
categoryIds,
|
||||
channels,
|
||||
featured,
|
||||
attributes,
|
||||
state,
|
||||
seoTitle: seoTitle || undefined,
|
||||
seoDescription: seoDesc || undefined,
|
||||
};
|
||||
let saved: Product;
|
||||
if (isCreate) saved = await productsApi.create(payload);
|
||||
else saved = await productsApi.update(productId, payload);
|
||||
snapRef.current = getSnap();
|
||||
dirtyRef.current = false;
|
||||
setSuccess(isCreate ? '¡Producto creado!' : 'Cambios guardados');
|
||||
if (isCreate) router.push(`/products/${saved.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAttr = (key: string) => {
|
||||
setAttributes(prev => prev.includes(key) ? prev.filter(a => a !== key) : [...prev, key]);
|
||||
};
|
||||
|
||||
const saveState = async (s: string) => {
|
||||
setState(s);
|
||||
if (productId) {
|
||||
try {
|
||||
await productsApi.setState(productId, s as 'active' | 'archived');
|
||||
setSuccess(`Estado actualizado a: ${s}`);
|
||||
} catch {
|
||||
setError('Error al cambiar estado');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="p-8 flex justify-center">
|
||||
<div className="h-6 w-6 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<button onClick={() => router.push('/products')} className="text-sm text-gray-500 hover:text-gray-700 mb-1 flex items-center gap-1">← Productos</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{isCreate ? 'Nuevo producto' : `Editar: ${name}`}</h1>
|
||||
</div>
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Guardando...' : isCreate ? 'Crear producto' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{error}</div>}
|
||||
{success && <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700">{success}</div>}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="hidden md:flex border-b border-gray-200 mb-8">
|
||||
{(['general', 'pricing', 'inventory', 'images', 'seo', 'publish'] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
tab === t ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}>
|
||||
{t === 'general' ? 'General' : t === 'pricing' ? 'Precios' : t === 'inventory' ? 'Inventario' : t === 'images' ? 'Imágenes' : t === 'seo' ? 'SEO' : 'Publicar'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── GENERAL ── */}
|
||||
{tab === 'general' && (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Nombre del producto *</label>
|
||||
<input type="text" value={name} onChange={e => handleNameChange(e.target.value)} required
|
||||
placeholder="Ej: Almendras Crudas Ecológicas"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-semibold text-gray-900">Slug (URL)</label>
|
||||
<span className={`text-xs ${slugManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{slugManual ? 'editado manualmente' : 'auto-generado'}
|
||||
</span>
|
||||
</div>
|
||||
<input type="text" value={slug}
|
||||
onChange={e => { setSlugManual(true); setSlug(e.target.value); }}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Descripción</label>
|
||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={4}
|
||||
placeholder="Descripción detallada del producto..."
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Marca</label>
|
||||
<select value={brandId} onChange={e => setBrandId(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white">
|
||||
<option value="">Sin marca</option>
|
||||
{brands.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-1.5">Canal de venta</label>
|
||||
<select value={channels} onChange={e => setChannels(e.target.value as typeof channels)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white">
|
||||
{CHANNEL_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 mb-3">
|
||||
<input type="checkbox" checked={featured} onChange={e => setFeatured(e.target.checked)}
|
||||
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
|
||||
<span className="text-sm font-semibold text-gray-900">⭐ Producto destacado</span>
|
||||
<span className="text-xs text-gray-400">(aparece en la home)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-semibold text-gray-900">Categorías</label>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-xl p-3 space-y-2 max-h-52 overflow-y-auto">
|
||||
{categories.map(cat => (
|
||||
<label key={cat.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={categoryIds.includes(cat.id)}
|
||||
onChange={e => {
|
||||
if (e.target.checked) setCategoryIds(prev => [...prev, cat.id]);
|
||||
else setCategoryIds(prev => prev.filter(id => id !== cat.id));
|
||||
}}
|
||||
className="rounded text-[#2D6A4F] focus:ring-[#2D6A4F]" />
|
||||
<span className={`text-sm ${cat.parentId ? 'text-gray-500' : 'font-medium text-gray-700'}`}>
|
||||
{cat.parentId ? `↳ ${cat.name}` : cat.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-semibold text-gray-900">Atributos</label>
|
||||
<span className="text-xs text-gray-400">{attributes.length} / 16</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Object.entries(ATTRIBUTE_LABELS).map(([key, label]) => (
|
||||
<label key={key}
|
||||
className={`flex items-center gap-2 px-3 py-2 border rounded-xl cursor-pointer transition-colors text-sm ${
|
||||
attributes.includes(key)
|
||||
? 'border-[#2D6A4F] bg-[#2D6A4F]/5 text-[#2D6A4F]'
|
||||
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
||||
}`}>
|
||||
<input type="checkbox" checked={attributes.includes(key)}
|
||||
onChange={() => toggleAttr(key)} className="hidden" />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── PRICING ── */}
|
||||
{tab === 'pricing' && (
|
||||
<section>
|
||||
{!productId ? (
|
||||
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Guarda primero el producto para configurar precios.
|
||||
</div>
|
||||
) : (
|
||||
<PricingSection productId={productId} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── INVENTORY ── */}
|
||||
{tab === 'inventory' && (
|
||||
<section>
|
||||
{!productId ? (
|
||||
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Guarda primero el producto para gestionar inventario.
|
||||
</div>
|
||||
) : (
|
||||
<InventorySection productId={productId} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── IMAGES ── */}
|
||||
{tab === 'images' && (
|
||||
<section>
|
||||
{!productId ? (
|
||||
<div className="p-6 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Guarda primero el producto para subir imágenes.
|
||||
</div>
|
||||
) : (
|
||||
<ImagesSection productId={productId} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── SEO ── */}
|
||||
{tab === 'seo' && (
|
||||
<section className="space-y-6">
|
||||
<div className="p-4 bg-blue-50 border border-blue-100 rounded-xl text-xs text-blue-700 space-y-1">
|
||||
<p>El título y descripción SEO se usan en Google. Si están vacíos, se usan automáticamente.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-semibold text-gray-900">Título SEO (Google)</label>
|
||||
<span className={`text-xs ${seoTitleManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{seoTitleManual ? 'editado manualmente' : 'copiado del nombre'}
|
||||
</span>
|
||||
</div>
|
||||
<input type="text" value={seoTitle}
|
||||
onChange={e => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
|
||||
maxLength={60}
|
||||
placeholder="Título para Google (max 60 caracteres)"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
<div className="mt-1 text-xs text-gray-400">{seoTitle.length}/60</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-semibold text-gray-900">Descripción SEO (Google)</label>
|
||||
<span className={`text-xs ${seoDescManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{seoDescManual ? 'editada manualmente' : 'auto-generada'}
|
||||
</span>
|
||||
</div>
|
||||
<textarea value={seoDesc}
|
||||
onChange={e => { setSeoDescManual(true); setSeoDesc(e.target.value); }}
|
||||
rows={3} maxLength={160}
|
||||
placeholder="Descripción para Google (max 160 caracteres)"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
|
||||
<div className="mt-1 text-xs text-gray-400">{seoDesc.length}/160</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── PUBLISH ── */}
|
||||
{tab === 'publish' && (
|
||||
<section className="space-y-5">
|
||||
<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">
|
||||
{[['draft', 'Borrador', 'gray'], ['active', 'Activo', 'green'], ['archived', 'Archivado', 'amber']].map(([s, label, color]) => (
|
||||
<button key={s} onClick={() => saveState(s as string)}
|
||||
className={`px-4 py-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
state === s
|
||||
? color === 'green' ? 'border-[#2D6A4F] bg-[#2D6A4F]/5 text-[#2D6A4F]' : color === 'amber' ? 'border-amber-400 bg-amber-50 text-amber-700' : 'border-gray-400 bg-gray-100 text-gray-700'
|
||||
: 'border-gray-200 text-gray-500 hover:border-gray-300'
|
||||
}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 bg-gray-50 border border-gray-200 rounded-xl text-sm text-gray-600 space-y-2">
|
||||
<div className="flex justify-between"><span>Borrador</span><span>No visible en la tienda</span></div>
|
||||
<div className="flex justify-between"><span>Activo</span><span>Visible y comprable online</span></div>
|
||||
<div className="flex justify-between"><span>Archivado</span><span>Oculto pero conservado</span></div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { productsApi } from '@/lib/api-client';
|
||||
import type { ProductImage } from '@/types';
|
||||
|
||||
interface ImagesSectionProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function ImagesSection({ productId }: ImagesSectionProps) {
|
||||
const [images, setImages] = useState<ProductImage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const [savingUrl, setSavingUrl] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const p = await productsApi.get(productId);
|
||||
setImages(p.images ?? []);
|
||||
} catch {
|
||||
setError('Error al cargar imágenes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [productId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const addImageByUrl = async (url: string) => {
|
||||
if (!url.trim()) return;
|
||||
setSavingUrl(true);
|
||||
try {
|
||||
const p = await productsApi.update(productId, {});
|
||||
// Attach via images array — for now use the attach endpoint
|
||||
await fetch(`/api/products/${productId}/images`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', credentials: 'include' },
|
||||
body: JSON.stringify({ url: url.trim(), altText: '', role: 'gallery' }),
|
||||
});
|
||||
setUrlInput('');
|
||||
load();
|
||||
} catch {
|
||||
setError('Error al añadir imagen');
|
||||
} finally {
|
||||
setSavingUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: fd, credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? 'Error al subir');
|
||||
}
|
||||
const { url } = await res.json() as { url: string };
|
||||
await fetch(`/api/products/${productId}/images`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', credentials: 'include' },
|
||||
body: JSON.stringify({ url, altText: '', role: 'gallery' }),
|
||||
});
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al subir');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) uploadFile(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const setMain = async (imageId: string) => {
|
||||
// Reorder: put this image first
|
||||
await fetch(`/api/products/${productId}/images/reorder`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', credentials: 'include' },
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{ imageId, position: 0 },
|
||||
...images.filter(i => i.id !== imageId).map((img, idx) => ({ imageId: img.id, position: idx + 1 })),
|
||||
],
|
||||
}),
|
||||
});
|
||||
load();
|
||||
};
|
||||
|
||||
const deleteImage = async (imageId: string) => {
|
||||
await fetch(`/api/products/${productId}/images/${imageId}`, { method: 'DELETE', credentials: 'include' });
|
||||
load();
|
||||
};
|
||||
|
||||
if (!productId) {
|
||||
return <div className="p-4 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
|
||||
⚠️ Guarda primero el producto para gestionar imágenes.
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-gray-400 text-sm">Cargando imágenes...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Upload / URL / Drag&drop */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
value={urlInput}
|
||||
onChange={e => setUrlInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addImageByUrl(urlInput)}
|
||||
placeholder="Pega una URL de imagen..."
|
||||
className="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addImageByUrl(urlInput)}
|
||||
disabled={savingUrl || !urlInput.trim()}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{savingUrl ? 'Añadiendo...' : 'Añadir URL'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="px-5 py-2.5 border border-gray-300 hover:border-[#2D6A4F] text-gray-700 text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
{uploading ? 'Subiendo...' : '📤 Subir imagen'}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleFileInput} />
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
|
||||
dragOver ? 'border-[#2D6A4F] bg-[#2D6A4F]/5' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<p className="text-gray-400 text-sm">
|
||||
🖼️ Arrastra imágenes aquí para añadirlas al producto
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{images.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400 text-sm border border-dashed border-gray-300 rounded-xl">
|
||||
No hay imágenes para este producto
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{images.map((img, idx) => (
|
||||
<div key={img.id} className="relative group">
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.altText ?? img.url}
|
||||
className="w-full aspect-square object-cover rounded-xl bg-gray-100"
|
||||
/>
|
||||
{/* Main badge */}
|
||||
{idx === 0 && (
|
||||
<span className="absolute top-2 left-2 px-2 py-0.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-full">
|
||||
Principal
|
||||
</span>
|
||||
)}
|
||||
{/* Actions */}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center gap-2">
|
||||
{idx !== 0 && (
|
||||
<button
|
||||
onClick={() => setMain(img.id)}
|
||||
className="px-2 py-1 bg-white text-gray-800 text-xs rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
Principal
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteImage(img.id)}
|
||||
className="p-2 bg-white text-red-600 rounded-lg hover:bg-red-50"
|
||||
title="Eliminar"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { productsApi, inventoryApi, pricingApi } from '@/lib/api-client';
|
||||
import type { ProductVariant, VariantPrice, StockAvailability } from '@/types';
|
||||
|
||||
interface VariantRow {
|
||||
variant: ProductVariant;
|
||||
price: VariantPrice | null;
|
||||
stock: StockAvailability | null;
|
||||
loadingStock: boolean;
|
||||
loadingPrice: boolean;
|
||||
editingStock: boolean;
|
||||
editingPrice: boolean;
|
||||
stockValue: string;
|
||||
priceValue: string;
|
||||
vatRate: 'general' | 'reduced';
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
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>>({});
|
||||
|
||||
// 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,
|
||||
stockValue: '',
|
||||
priceValue: '',
|
||||
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: String(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: String(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) 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,
|
||||
},
|
||||
}));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
|
||||
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch {
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
|
||||
} finally {
|
||||
setSavingVariant(null);
|
||||
}
|
||||
};
|
||||
|
||||
const savePrice = async (variantId: string) => {
|
||||
const r = rows[variantId];
|
||||
const cents = parseInt(r.priceValue, 10);
|
||||
if (isNaN(cents) || cents < 0) 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,
|
||||
},
|
||||
}));
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: '✓ Guardado' }));
|
||||
setTimeout(() => setSaveMsg((prev) => ({ ...prev, [variantId]: '' })), 3000);
|
||||
} catch {
|
||||
setSaveMsg((prev) => ({ ...prev, [variantId]: 'Error' }));
|
||||
} finally {
|
||||
setSavingVariant(null);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
Las variantes se crean desde la pestaña Publicar
|
||||
</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 === 'general' ? 1.21 : 1.1)) / 100
|
||||
: null;
|
||||
|
||||
return (
|
||||
<tr key={variant.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
{/* SKU */}
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{variant.sku}</td>
|
||||
|
||||
{/* EAN */}
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-500">{variant.ean ?? '—'}</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
|
||||
type="number"
|
||||
min={0}
|
||||
value={r.priceValue}
|
||||
onChange={(e) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: { ...prev[variant.id], priceValue: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-gray-900">
|
||||
{r.price ? formatCents(r.price.netUnitAmountCents) : '—'}
|
||||
</span>
|
||||
{r.price && (
|
||||
<button
|
||||
onClick={() => startEditPrice(variant.id)}
|
||||
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
|
||||
title="Editar precio"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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',
|
||||
},
|
||||
}))
|
||||
}
|
||||
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>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">
|
||||
{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
|
||||
type="number"
|
||||
min={0}
|
||||
value={r.stockValue}
|
||||
onChange={(e) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[variant.id]: { ...prev[variant.id], stockValue: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="w-16 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveStock(variant.id)}
|
||||
disabled={savingVariant === variant.id}
|
||||
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
|
||||
>
|
||||
{savingVariant === variant.id ? '...' : 'OK'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => cancelEditStock(variant.id)}
|
||||
className="text-gray-400 hover:text-gray-600 text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-gray-900">
|
||||
{r.stock?.availableQuantity ?? '—'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => startEditStock(variant.id)}
|
||||
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
|
||||
title="Editar stock"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
{r.editingPrice && (
|
||||
<button
|
||||
onClick={() => savePrice(variant.id)}
|
||||
disabled={savingVariant === variant.id}
|
||||
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
|
||||
>
|
||||
{savingVariant === variant.id ? '...' : 'OK'}
|
||||
</button>
|
||||
)}
|
||||
{r.editingPrice && (
|
||||
<button
|
||||
onClick={() => cancelEditPrice(variant.id)}
|
||||
className="text-gray-400 hover:text-gray-600 text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{saveMsg[variant.id] && !r.editingStock && !r.editingPrice && (
|
||||
<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 === 'general' ? 1.21 : 1.1),
|
||||
),
|
||||
)
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { productsApi, pricingApi } from '@/lib/api-client';
|
||||
import type { ProductVariant, VariantPrice } from '@/types';
|
||||
|
||||
const VAT_GENERAL = 1.21;
|
||||
const VAT_REDUCED = 1.10;
|
||||
|
||||
function fmt(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function calcGross(netCents: number, vatRate: 'general' | 'reduced'): number {
|
||||
return Math.round(netCents * (vatRate === 'general' ? VAT_GENERAL : VAT_REDUCED));
|
||||
}
|
||||
|
||||
function calcMarginBruto(grossCents: number, costCents: number): number {
|
||||
if (grossCents === 0) return 0;
|
||||
return Math.round(((grossCents - costCents) / grossCents) * 100);
|
||||
}
|
||||
|
||||
interface PricingSectionProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export function PricingSection({ productId }: PricingSectionProps) {
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [loadingVariants, setLoadingVariants] = useState(true);
|
||||
const [loadingPrices, setLoadingPrices] = useState(true);
|
||||
const [prices, setPrices] = useState<Record<string, VariantPrice>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState<Record<string, string>>({});
|
||||
|
||||
// Edit state per variant
|
||||
const [net, setNet] = useState<Record<string, string>>({});
|
||||
const [offer, setOffer] = useState<Record<string, string>>({});
|
||||
const [cost, setCost] = useState<Record<string, string>>({});
|
||||
const [vatRate, setVatRate] = useState<Record<string, 'general' | '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) => {
|
||||
setPrices(prev => ({ ...prev, [v.id]: p }));
|
||||
setNet(prev => ({ ...prev, [v.id]: String(p.netUnitAmountCents) }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: p.offerCents !== null ? String(p.offerCents) : '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: p.costCents !== null ? String(p.costCents) : '' }));
|
||||
setVatRate(prev => ({ ...prev, [v.id]: p.vatRate }));
|
||||
})
|
||||
.catch(() => {
|
||||
setNet(prev => ({ ...prev, [v.id]: '0' }));
|
||||
setOffer(prev => ({ ...prev, [v.id]: '' }));
|
||||
setCost(prev => ({ ...prev, [v.id]: '' }));
|
||||
setVatRate(prev => ({ ...prev, [v.id]: 'general' }));
|
||||
})
|
||||
.finally(() => {
|
||||
done++;
|
||||
if (done >= variants.length) setLoadingPrices(false);
|
||||
});
|
||||
}
|
||||
}, [variants]);
|
||||
|
||||
const savePrice = async (variantId: string) => {
|
||||
const netCents = parseInt(net[variantId] ?? '0', 10);
|
||||
const offerCentsVal = offer[variantId] ? parseInt(offer[variantId], 10) : null;
|
||||
const costCentsVal = cost[variantId] ? parseInt(cost[variantId], 10) : null;
|
||||
if (isNaN(netCents) || netCents < 0) return;
|
||||
if (offerCentsVal !== null && (isNaN(offerCentsVal) || offerCentsVal < 0)) return;
|
||||
if (costCentsVal !== null && (isNaN(costCentsVal) || costCentsVal < 0)) return;
|
||||
setSaving(variantId);
|
||||
setMsg(prev => ({ ...prev, [variantId]: '' }));
|
||||
try {
|
||||
const updated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId]);
|
||||
if (offerCentsVal !== null) {
|
||||
// set offer via separate update
|
||||
const offerUpdated = await pricingApi.setVariantPrice(variantId, netCents, vatRate[variantId], offerCentsVal, costCentsVal);
|
||||
setPrices(prev => ({ ...prev, [variantId]: offerUpdated }));
|
||||
} else {
|
||||
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. Las variantes se crean desde la pestaña Publicar.
|
||||
</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 p = prices[v.id];
|
||||
const netCents = parseInt(net[v.id] ?? '0', 10);
|
||||
const costCents = cost[v.id] ? parseInt(cost[v.id], 10) : 0;
|
||||
const vr = vatRate[v.id] ?? 'general';
|
||||
const grossCents = calcGross(netCents, vr);
|
||||
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="number" min={0} step={1}
|
||||
value={cost[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setCost(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
className="w-20 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="number" min={0} step={1}
|
||||
value={grossCents}
|
||||
disabled={editing}
|
||||
onChange={e => {
|
||||
const gross = parseInt(e.target.value, 10) || 0;
|
||||
const newNet = Math.round(gross / (vr === 'general' ? VAT_GENERAL : VAT_REDUCED));
|
||||
setNet(prev => ({ ...prev, [v.id]: String(newNet) }));
|
||||
}}
|
||||
className="w-20 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="number" min={0} step={1}
|
||||
value={offer[v.id] ?? ''}
|
||||
disabled={editing}
|
||||
onChange={e => setOffer(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
placeholder="—"
|
||||
className="w-20 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 => setVatRate(prev => ({ ...prev, [v.id]: e.target.value as 'general' | 'reduced' }))}
|
||||
className="px-2 py-1 border border-gray-300 rounded-lg text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="general">21% gen.</option>
|
||||
<option value="reduced">10% red.</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="number" min={0} step={1}
|
||||
value={netCents}
|
||||
disabled={editing}
|
||||
onChange={e => setNet(prev => ({ ...prev, [v.id]: e.target.value }))}
|
||||
className="w-20 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>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> 21% general (alimentación procesada) · 10% reducido (alimentos básicos, frutas, verduras).</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user