'use client'; import { useState, useEffect, useCallback } from 'react'; import { productsApi, inventoryApi, pricingApi } from '@/lib/api-client'; import type { ProductVariant, StockAvailability, VariantPrice } from '@/types'; interface ProductRow { productId: string; productName: string; variant: ProductVariant; stock: StockAvailability | null; price: VariantPrice | null; expirationDate: string | null; loading: boolean; editing: boolean; editValue: string; saving: boolean; msg: string; editingEan: boolean; editEanValue: string; savingEan: boolean; } type RowFilter = 'all' | 'in_stock' | 'low_stock' | 'out_of_stock' | 'expiring' | 'low_margin'; const FILTER_LABELS: Record = { all: 'Todos', in_stock: 'En stock', low_stock: 'Stock bajo', out_of_stock: 'Sin stock', expiring: '⏳ Por caducar (<30 días)', low_margin: '📉 Margen <30%', }; const VAT_MULTIPLIER: Record = { general: 1.21, reduced: 1.1, 'super-reduced': 1.04, }; function StockBadge({ qty }: { qty: number }) { if (qty === 0) return Sin stock; if (qty < 5) return Stock bajo ({qty}); return En stock ({qty}); } /** Días hasta la fecha de caducidad (negativo si ya caducó). */ function daysUntil(dateIso: string): number { const target = new Date(`${dateIso.slice(0, 10)}T00:00:00`); const today = new Date(); today.setHours(0, 0, 0, 0); return Math.round((target.getTime() - today.getTime()) / 86_400_000); } /** Margen bruto % = (PVP − coste) / PVP × 100. Null si falta coste o precio. */ function marginPercent(price: VariantPrice | null): number | null { if (!price || price.costCents === null) return null; const grossCents = Math.round(price.netUnitAmountCents * (VAT_MULTIPLIER[price.vatRate] ?? 1.21)); if (grossCents <= 0) return null; return Math.round(((grossCents - price.costCents) / grossCents) * 100); } function ExpirationCell({ dateIso }: { dateIso: string | null }) { if (!dateIso) return ; const days = daysUntil(dateIso); const label = new Date(`${dateIso.slice(0, 10)}T00:00:00`).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric', }); if (days < 0) { return Caducado ({label}); } if (days <= 30) { return ( Caduca en {days} día{days === 1 ? '' : 's'} ); } return {label}; } export default function InventoryPage() { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [filter, setFilter] = useState('all'); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); useEffect(() => { const t = setTimeout(() => setDebouncedSearch(search), 400); return () => clearTimeout(t); }, [search]); // Load products + single internal variant + stock + price (F-108/F-110) const load = useCallback(async () => { setLoading(true); setError(''); try { const { items: products } = await productsApi.list({ limit: 100, q: debouncedSearch || undefined, }); const productRows: ProductRow[] = []; for (const product of products ?? []) { const { items: variants } = await productsApi.getVariants(product.id); const variant = variants?.[0]; if (!variant) continue; productRows.push({ productId: product.id, productName: product.name, variant, stock: null, price: null, expirationDate: product.expirationDate ?? null, loading: true, editing: false, editValue: '', saving: false, msg: '', editingEan: false, editEanValue: '', savingEan: false, }); } setRows(productRows); for (const row of productRows) { inventoryApi.getAvailability(row.variant.id) .then((stock) => { setRows((prev) => prev.map((r) => r.variant.id === row.variant.id ? { ...r, stock, loading: false, editValue: String(stock.availableQuantity) } : r, ), ); }) .catch(() => { setRows((prev) => prev.map((r) => r.variant.id === row.variant.id ? { ...r, loading: false, editValue: '0' } : r, ), ); }); pricingApi.getVariantPrice(row.variant.id) .then((price) => { setRows((prev) => prev.map((r) => (r.variant.id === row.variant.id ? { ...r, price } : r)), ); }) .catch(() => { // Sin precio: margen no disponible. }); } } catch (e) { setError(e instanceof Error ? e.message : 'Error al cargar inventario'); } finally { setLoading(false); } }, [debouncedSearch]); useEffect(() => { load(); }, [load]); // Save EAN inline const handleSaveEan = async (variantId: string, productId: string, newEan: string) => { setRows(prev => prev.map(r => r.variant.id === variantId ? { ...r, savingEan: true } : r)); try { const updated = await productsApi.updateVariant(productId, variantId, { ean: newEan || null }); setRows(prev => prev.map(r => r.variant.id === variantId ? { ...r, variant: { ...r.variant, ean: updated.ean }, editingEan: false, savingEan: false } : r)); } catch { setRows(prev => prev.map(r => r.variant.id === variantId ? { ...r, savingEan: false } : r)); } }; // Save Stock inline (used by Stock cell on blur/Enter) const saveStockInline = async (variantId: string, value: string) => { const qty = parseInt(value, 10); if (isNaN(qty) || qty < 0) { setRows((prev) => prev.map((r) => r.variant.id === variantId ? { ...r, editing: false, editValue: String(r.stock?.availableQuantity ?? 0), msg: 'Error' } : r, ), ); return; } setRows((prev) => prev.map((r) => (r.variant.id === variantId ? { ...r, saving: true } : r))); try { const result = await inventoryApi.setStock(variantId, qty); setRows((prev) => prev.map((r) => r.variant.id === variantId ? { ...r, stock: { available: result.available > 0, availableQuantity: result.available }, editing: false, saving: false, editValue: String(result.available), msg: '✓ Guardado', } : r, ), ); setTimeout(() => { setRows((prev) => prev.map((r) => (r.variant.id === variantId ? { ...r, msg: '' } : r))); }, 3000); } catch { setRows((prev) => prev.map((r) => r.variant.id === variantId ? { ...r, saving: false, editing: false, editValue: String(r.stock?.availableQuantity ?? 0), msg: 'Error', } : r, ), ); } }; // Filter + sort rows const filtered = (() => { let result = rows.filter((r) => { const qty = r.stock?.availableQuantity ?? 0; if (filter === 'in_stock') return qty >= 5; if (filter === 'low_stock') return qty > 0 && qty < 5; if (filter === 'out_of_stock') return qty === 0; if (filter === 'expiring') { return r.expirationDate !== null && daysUntil(r.expirationDate) <= 30; } if (filter === 'low_margin') { const margin = marginPercent(r.price); return margin !== null && margin < 30; } return true; }); if (filter === 'expiring') { result = [...result].sort( (a, b) => daysUntil(a.expirationDate ?? '') - daysUntil(b.expirationDate ?? ''), ); } else if (filter === 'low_margin') { result = [...result].sort( (a, b) => (marginPercent(a.price) ?? 0) - (marginPercent(b.price) ?? 0), ); } return result; })(); const inStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) >= 5).length; const lowStockCount = rows.filter((r) => { const q = r.stock?.availableQuantity ?? 0; return q > 0 && q < 5; }).length; const outOfStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) === 0).length; return (
{/* Header */}

Inventario

{rows.length} productos

{/* Stats */}
{[ { label: 'En stock', count: inStockCount, cls: 'bg-green-50 border-green-100 text-green-700' }, { label: 'Stock bajo', count: lowStockCount, cls: 'bg-amber-50 border-amber-100 text-amber-700' }, { label: 'Sin stock', count: outOfStockCount, cls: 'bg-red-50 border-red-100 text-red-700' }, ].map(({ label, count, cls }) => (

{count}

{label}

))}
{/* Search + filters */}
setSearch(e.target.value)} className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none" />
{(Object.keys(FILTER_LABELS) as RowFilter[]).map((f) => ( ))}
{/* Table */}
{loading ? (
Cargando inventario...
) : error ? (

{error}

) : filtered.length === 0 ? (

📦

No hay productos para este filtro

) : (
{filtered.map((row) => { const margin = marginPercent(row.price); return ( ); })}
Producto EAN Caducidad Margen Stock Estado

{row.productName}

{row.editingEan ? ( setRows(prev => prev.map(r => r.variant.id === row.variant.id ? { ...r, editEanValue: e.target.value } : r))} onBlur={() => handleSaveEan(row.variant.id, row.productId, row.editEanValue)} onKeyDown={e => { if (e.key === 'Enter') handleSaveEan(row.variant.id, row.productId, row.editEanValue); if (e.key === 'Escape') setRows(prev => prev.map(r => r.variant.id === row.variant.id ? { ...r, editingEan: false } : r)); }} disabled={row.savingEan} placeholder="—" className="w-full px-2 py-1 border border-[#2D6A4F] rounded text-xs focus:ring-1 focus:ring-[#2D6A4F] outline-none disabled:opacity-50" /> ) : ( )} {margin === null ? ( ) : ( = 30 ? 'text-green-600' : margin >= 10 ? 'text-amber-600' : 'text-red-600'}`}> {margin}% )} {row.loading ? ( ) : row.editing ? (
setRows((prev) => prev.map((r) => r.variant.id === row.variant.id ? { ...r, editValue: e.target.value } : r, ), ) } onBlur={() => saveStockInline(row.variant.id, row.editValue)} onKeyDown={(e) => { if (e.key === 'Enter') saveStockInline(row.variant.id, row.editValue); if (e.key === 'Escape') setRows((prev) => prev.map((r) => r.variant.id === row.variant.id ? { ...r, editing: false, editValue: String(r.stock?.availableQuantity ?? 0), } : r, ), ); }} disabled={row.saving} className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none" /> {row.msg && ( {row.msg} )}
) : ( )}
)}
); }