Files
mercadodevida/project/apps/admin/src/app/(dashboard)/inventory/page.tsx
2026-08-21 10:05:01 +02:00

477 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<RowFilter, string> = {
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<string, number> = {
general: 1.21,
reduced: 1.1,
'super-reduced': 1.04,
};
function StockBadge({ qty }: { qty: number }) {
if (qty === 0) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">Sin stock</span>;
if (qty < 5) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">Stock bajo ({qty})</span>;
return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">En stock ({qty})</span>;
}
/** 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 <span className="text-gray-300"></span>;
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 <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">Caducado ({label})</span>;
}
if (days <= 30) {
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700" title={label}>
Caduca en {days} día{days === 1 ? '' : 's'}
</span>
);
}
return <span className="text-xs text-gray-600">{label}</span>;
}
export default function InventoryPage() {
const [rows, setRows] = useState<ProductRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState<RowFilter>('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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Inventario</h1>
<p className="text-sm text-gray-500 mt-0.5">{rows.length} productos</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
{[
{ 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 }) => (
<div key={label} className={`p-4 rounded-xl border ${cls}`}>
<p className="text-2xl font-bold">{count}</p>
<p className="text-sm font-medium">{label}</p>
</div>
))}
</div>
{/* Search + filters */}
<div className="flex items-center gap-4 flex-wrap">
<div className="relative flex-1 max-w-sm min-w-52">
<input
type="search"
id="admin-inventory-search"
name="q"
placeholder="Buscar por EAN o nombre..."
value={search}
onChange={(e) => 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"
/>
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
</svg>
</div>
<div className="flex gap-2 flex-wrap">
{(Object.keys(FILTER_LABELS) as RowFilter[]).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
filter === f
? 'bg-[#2D6A4F] text-white'
: 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'
}`}
>
{FILTER_LABELS[f]}
</button>
))}
</div>
<button
onClick={load}
className="text-sm text-[#2D6A4F] hover:underline"
>
Recargar
</button>
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{loading ? (
<div className="p-12 flex items-center justify-center gap-3 text-gray-400">
<div className="h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
<span className="text-sm">Cargando inventario...</span>
</div>
) : error ? (
<div className="text-center">
<p className="text-red-600 text-sm mb-3">{error}</p>
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
</div>
) : filtered.length === 0 ? (
<div className="p-12 text-center">
<p className="text-4xl mb-3">📦</p>
<p className="text-gray-500 text-sm">No hay productos para este filtro</p>
</div>
) : (
<div className="overflow-x-auto">
<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">Producto</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">Caducidad</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Margen</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-50">
{filtered.map((row) => {
const margin = marginPercent(row.price);
return (
<tr key={row.variant.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-4 py-3">
<p className="text-sm font-medium text-gray-900">{row.productName}</p>
</td>
<td className="px-4 py-3">
{row.editingEan ? (
<input
autoFocus
value={row.editEanValue}
onChange={e => 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"
/>
) : (
<button
onClick={() => setRows(prev => prev.map(r => r.variant.id === row.variant.id ? { ...r, editingEan: true, editEanValue: row.variant.ean ?? '' } : r))}
title="Clic para editar EAN"
className="font-mono text-xs text-gray-400 hover:text-[#2D6A4F] cursor-text text-left w-full truncate block disabled:opacity-50"
>
{row.variant.ean ?? '—'}
</button>
)}
</td>
<td className="px-4 py-3">
<ExpirationCell dateIso={row.expirationDate} />
</td>
<td className="px-4 py-3">
{margin === null ? (
<span className="text-gray-300"></span>
) : (
<span className={`text-xs font-bold ${margin >= 30 ? 'text-green-600' : margin >= 10 ? 'text-amber-600' : 'text-red-600'}`}>
{margin}%
</span>
)}
</td>
<td className="px-4 py-3">
{row.loading ? (
<span className="text-gray-300"></span>
) : row.editing ? (
<div className="flex items-center gap-2">
<input
autoFocus
type="number"
min={0}
value={row.editValue}
onChange={(e) =>
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 && (
<span className={`text-xs ${row.msg === '✓' || row.msg === '✓ Guardado' ? 'text-green-600' : 'text-red-600'}`}>
{row.msg}
</span>
)}
</div>
) : (
<button
onClick={() =>
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id
? { ...r, editing: true, editValue: String(r.stock?.availableQuantity ?? 0) }
: r,
),
)
}
title="Clic para editar stock"
className="font-medium text-gray-900 hover:text-[#2D6A4F] cursor-text disabled:opacity-50"
>
{row.stock?.availableQuantity ?? '—'}
</button>
)}
</td>
<td className="px-4 py-3">
<StockBadge qty={row.stock?.availableQuantity ?? 0} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}