feat(F-075): completed feature

This commit is contained in:
chattie
2026-08-19 19:29:11 +02:00
parent e699285b1d
commit a17dad6bd0
12 changed files with 301 additions and 59 deletions

View File

@@ -13,6 +13,12 @@ interface VariantRow {
editValue: string;
saving: boolean;
msg: string;
editingSku: boolean;
editSkuValue: string;
savingSku: boolean;
editingEan: boolean;
editEanValue: string;
savingEan: boolean;
}
type StockFilter = 'all' | 'in_stock' | 'low_stock' | 'out_of_stock';
@@ -69,6 +75,12 @@ export default function InventoryPage() {
editValue: '',
saving: false,
msg: '',
editingSku: false,
editSkuValue: '',
savingSku: false,
editingEan: false,
editEanValue: '',
savingEan: false,
});
}
}
@@ -104,6 +116,30 @@ export default function InventoryPage() {
useEffect(() => { load(); }, [load]);
// Save SKU inline
const handleSaveSku = async (variantId: string, productId: string, newSku: string) => {
if (!newSku.trim()) return;
setRows(prev => prev.map(r => r.variant.id === variantId ? { ...r, savingSku: true } : r));
try {
const updated = await productsApi.updateVariant(productId, variantId, { sku: newSku.trim() });
setRows(prev => prev.map(r => r.variant.id === variantId ? { ...r, variant: { ...r.variant, sku: updated.sku }, editingSku: false, savingSku: false, msg: '✓' } : 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, savingSku: false, msg: 'Error' } : r));
}
};
// 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));
}
};
// Filter rows
const filtered = rows.filter((r) => {
if (filter === 'in_stock') return (r.stock?.availableQuantity ?? 0) >= 5;
@@ -211,7 +247,6 @@ export default function InventoryPage() {
<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">Stock</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Estado</th>
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Acción</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
@@ -220,8 +255,49 @@ export default function InventoryPage() {
<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 font-mono text-xs text-gray-600">{row.variant.sku}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-400">{row.variant.ean ?? '—'}</td>
<td className="px-4 py-3">
{row.editingSku ? (
<input
autoFocus
value={row.editSkuValue}
onChange={e => setRows(prev => prev.map(r => r.variant.id === row.variant.id ? { ...r, editSkuValue: e.target.value } : r))}
onBlur={() => handleSaveSku(row.variant.id, row.productId, row.editSkuValue)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveSku(row.variant.id, row.productId, row.editSkuValue); if (e.key === 'Escape') setRows(prev => prev.map(r => r.variant.id === row.variant.id ? { ...r, editingSku: false } : r)); }}
disabled={row.savingSku}
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, editingSku: true, editSkuValue: row.variant.sku } : r))}
title="Clic para editar SKU"
className="font-mono text-xs text-gray-600 hover:text-[#2D6A4F] cursor-text text-left w-full truncate block disabled:opacity-50"
>
{row.variant.sku}
</button>
)}
</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">
{row.loading ? (
<span className="text-gray-300"></span>
@@ -308,36 +384,21 @@ export default function InventoryPage() {
</button>
</div>
) : (
<div className="flex items-center gap-1">
<span className="font-medium text-gray-900">
{row.stock?.availableQuantity ?? '—'}
</span>
<button
onClick={() =>
setRows((prev) =>
prev.map((r) =>
r.variant.id === row.variant.id ? { ...r, editing: true } : r,
),
)
}
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
title="Editar stock"
>
</button>
</div>
<button
onClick={() =>
setRows(prev =>
prev.map(r => r.variant.id === row.variant.id ? { ...r, editing: true } : 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>
<td className="px-4 py-3">
{row.msg && (
<span className={`text-xs ${row.msg === '✓' ? 'text-green-600' : 'text-red-600'}`}>
{row.msg}
</span>
)}
</td>
</tr>
))}
</tbody>

View File

@@ -81,6 +81,8 @@ export const productsApi = {
setState: (id: string, state: 'active' | 'archived') =>
api.patch(`/api/products/${id}/state`, { state }),
delete: (id: string) => api.delete(`/api/products/${id}`),
updateVariant: (productId: string, variantId: string, data: unknown) =>
api.patch<import('@/types').ProductVariant>(`/api/products/${productId}/variants/${variantId}`, data),
};
// ── Orders ────────────────────────────────────────────────────────────────────