feat(F-110): completed feature
This commit is contained in:
@@ -4788,13 +4788,15 @@
|
|||||||
"Filter shows items with margin under 30 percent sorted ASC",
|
"Filter shows items with margin under 30 percent sorted ASC",
|
||||||
"verify.sh is green"
|
"verify.sh is green"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"created_at": "2026-08-21",
|
"created_at": "2026-08-21",
|
||||||
"gates": {
|
"gates": {
|
||||||
"reviewer": false,
|
"reviewer": true,
|
||||||
"security": false,
|
"security": true,
|
||||||
"qa": false
|
"qa": true,
|
||||||
}
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-21T08:05:01Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "F-111",
|
"id": "F-111",
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { productsApi, inventoryApi } from '@/lib/api-client';
|
import { productsApi, inventoryApi, pricingApi } from '@/lib/api-client';
|
||||||
import type { ProductVariant, StockAvailability } from '@/types';
|
import type { ProductVariant, StockAvailability, VariantPrice } from '@/types';
|
||||||
|
|
||||||
interface VariantRow {
|
interface ProductRow {
|
||||||
productId: string;
|
productId: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
variant: ProductVariant;
|
variant: ProductVariant;
|
||||||
stock: StockAvailability | null;
|
stock: StockAvailability | null;
|
||||||
|
price: VariantPrice | null;
|
||||||
|
expirationDate: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
editValue: string;
|
editValue: string;
|
||||||
@@ -18,13 +20,21 @@ interface VariantRow {
|
|||||||
savingEan: boolean;
|
savingEan: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StockFilter = 'all' | 'in_stock' | 'low_stock' | 'out_of_stock';
|
type RowFilter = 'all' | 'in_stock' | 'low_stock' | 'out_of_stock' | 'expiring' | 'low_margin';
|
||||||
|
|
||||||
const STOCK_LABELS: Record<StockFilter, string> = {
|
const FILTER_LABELS: Record<RowFilter, string> = {
|
||||||
all: 'Todos',
|
all: 'Todos',
|
||||||
in_stock: 'En stock',
|
in_stock: 'En stock',
|
||||||
low_stock: 'Stock bajo',
|
low_stock: 'Stock bajo',
|
||||||
out_of_stock: 'Sin stock',
|
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 }) {
|
function StockBadge({ qty }: { qty: number }) {
|
||||||
@@ -33,21 +43,55 @@ function StockBadge({ qty }: { qty: number }) {
|
|||||||
return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">En stock ({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() {
|
export default function InventoryPage() {
|
||||||
const [rows, setRows] = useState<VariantRow[]>([]);
|
const [rows, setRows] = useState<ProductRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [filter, setFilter] = useState<StockFilter>('all');
|
const [filter, setFilter] = useState<RowFilter>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
|
||||||
// Debounce search
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
// Load products + variants + stock
|
// Load products + single internal variant + stock + price (F-108/F-110)
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -57,41 +101,38 @@ export default function InventoryPage() {
|
|||||||
q: debouncedSearch || undefined,
|
q: debouncedSearch || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const variantRows: VariantRow[] = [];
|
const productRows: ProductRow[] = [];
|
||||||
|
|
||||||
for (const product of products ?? []) {
|
for (const product of products ?? []) {
|
||||||
// Modelo sin variantes: cada producto gestiona su stock/EAN a nivel de
|
|
||||||
// producto usando su única variante interna (F-108).
|
|
||||||
const { items: variants } = await productsApi.getVariants(product.id);
|
const { items: variants } = await productsApi.getVariants(product.id);
|
||||||
const variant = variants?.[0];
|
const variant = variants?.[0];
|
||||||
if (!variant) continue;
|
if (!variant) continue;
|
||||||
{
|
productRows.push({
|
||||||
variantRows.push({
|
productId: product.id,
|
||||||
productId: product.id,
|
productName: product.name,
|
||||||
productName: product.name,
|
variant,
|
||||||
variant,
|
stock: null,
|
||||||
stock: null,
|
price: null,
|
||||||
loading: true,
|
expirationDate: product.expirationDate ?? null,
|
||||||
editing: false,
|
loading: true,
|
||||||
editValue: '',
|
editing: false,
|
||||||
saving: false,
|
editValue: '',
|
||||||
msg: '',
|
saving: false,
|
||||||
editingEan: false,
|
msg: '',
|
||||||
editEanValue: '',
|
editingEan: false,
|
||||||
savingEan: false,
|
editEanValue: '',
|
||||||
});
|
savingEan: false,
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setRows(variantRows);
|
setRows(productRows);
|
||||||
|
|
||||||
// Load stock for each variant
|
for (const row of productRows) {
|
||||||
for (const vr of variantRows) {
|
inventoryApi.getAvailability(row.variant.id)
|
||||||
inventoryApi.getAvailability(vr.variant.id)
|
|
||||||
.then((stock) => {
|
.then((stock) => {
|
||||||
setRows((prev) =>
|
setRows((prev) =>
|
||||||
prev.map((r) =>
|
prev.map((r) =>
|
||||||
r.variant.id === vr.variant.id
|
r.variant.id === row.variant.id
|
||||||
? { ...r, stock, loading: false, editValue: String(stock.availableQuantity) }
|
? { ...r, stock, loading: false, editValue: String(stock.availableQuantity) }
|
||||||
: r,
|
: r,
|
||||||
),
|
),
|
||||||
@@ -100,10 +141,20 @@ export default function InventoryPage() {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
setRows((prev) =>
|
setRows((prev) =>
|
||||||
prev.map((r) =>
|
prev.map((r) =>
|
||||||
r.variant.id === vr.variant.id ? { ...r, loading: false, editValue: '0' } : 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) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Error al cargar inventario');
|
setError(e instanceof Error ? e.message : 'Error al cargar inventario');
|
||||||
@@ -175,13 +226,33 @@ export default function InventoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter rows
|
// Filter + sort rows
|
||||||
const filtered = rows.filter((r) => {
|
const filtered = (() => {
|
||||||
if (filter === 'in_stock') return (r.stock?.availableQuantity ?? 0) >= 5;
|
let result = rows.filter((r) => {
|
||||||
if (filter === 'low_stock') return (r.stock?.availableQuantity ?? 0) > 0 && (r.stock?.availableQuantity ?? 0) < 5;
|
const qty = r.stock?.availableQuantity ?? 0;
|
||||||
if (filter === 'out_of_stock') return (r.stock?.availableQuantity ?? 0) === 0;
|
if (filter === 'in_stock') return qty >= 5;
|
||||||
return true;
|
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 inStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) >= 5).length;
|
||||||
const lowStockCount = rows.filter((r) => {
|
const lowStockCount = rows.filter((r) => {
|
||||||
@@ -215,8 +286,8 @@ export default function InventoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + filters */}
|
{/* Search + filters */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
<div className="relative flex-1 max-w-sm">
|
<div className="relative flex-1 max-w-sm min-w-52">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
id="admin-inventory-search"
|
id="admin-inventory-search"
|
||||||
@@ -231,8 +302,8 @@ export default function InventoryPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{(Object.keys(STOCK_LABELS) as StockFilter[]).map((f) => (
|
{(Object.keys(FILTER_LABELS) as RowFilter[]).map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
@@ -242,7 +313,7 @@ export default function InventoryPage() {
|
|||||||
: 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'
|
: 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{STOCK_LABELS[f]}
|
{FILTER_LABELS[f]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -279,105 +350,122 @@ export default function InventoryPage() {
|
|||||||
<tr className="bg-gray-50 border-b border-gray-200 text-left">
|
<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">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">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">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">Estado</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-50">
|
<tbody className="divide-y divide-gray-50">
|
||||||
{filtered.map((row) => (
|
{filtered.map((row) => {
|
||||||
<tr key={row.variant.id} className="hover:bg-gray-50/50 transition-colors">
|
const margin = marginPercent(row.price);
|
||||||
<td className="px-4 py-3">
|
return (
|
||||||
<p className="text-sm font-medium text-gray-900">{row.productName}</p>
|
<tr key={row.variant.id} className="hover:bg-gray-50/50 transition-colors">
|
||||||
</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-4 py-3">
|
<p className="text-sm font-medium text-gray-900">{row.productName}</p>
|
||||||
{row.editingEan ? (
|
</td>
|
||||||
<input
|
<td className="px-4 py-3">
|
||||||
autoFocus
|
{row.editingEan ? (
|
||||||
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>
|
|
||||||
) : row.editing ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="number"
|
value={row.editEanValue}
|
||||||
min={0}
|
onChange={e => setRows(prev => prev.map(r => r.variant.id === row.variant.id ? { ...r, editEanValue: e.target.value } : r))}
|
||||||
value={row.editValue}
|
onBlur={() => handleSaveEan(row.variant.id, row.productId, row.editEanValue)}
|
||||||
onChange={(e) =>
|
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) =>
|
setRows((prev) =>
|
||||||
prev.map((r) =>
|
prev.map((r) =>
|
||||||
r.variant.id === row.variant.id
|
r.variant.id === row.variant.id
|
||||||
? { ...r, editValue: e.target.value }
|
? { ...r, editing: true, editValue: String(r.stock?.availableQuantity ?? 0) }
|
||||||
: r,
|
: r,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onBlur={() => saveStockInline(row.variant.id, row.editValue)}
|
title="Clic para editar stock"
|
||||||
onKeyDown={(e) => {
|
className="font-medium text-gray-900 hover:text-[#2D6A4F] cursor-text disabled:opacity-50"
|
||||||
if (e.key === 'Enter') saveStockInline(row.variant.id, row.editValue);
|
>
|
||||||
if (e.key === 'Escape')
|
{row.stock?.availableQuantity ?? '—'}
|
||||||
setRows((prev) =>
|
</button>
|
||||||
prev.map((r) =>
|
)}
|
||||||
r.variant.id === row.variant.id
|
</td>
|
||||||
? {
|
<td className="px-4 py-3">
|
||||||
...r,
|
<StockBadge qty={row.stock?.availableQuantity ?? 0} />
|
||||||
editing: false,
|
</td>
|
||||||
editValue: String(r.stock?.availableQuantity ?? 0),
|
</tr>
|
||||||
}
|
);
|
||||||
: 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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
12
work/artifacts/F-110/implementer.md
Normal file
12
work/artifacts/F-110/implementer.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# F-110 — Inventario: caducidad, margen y filtros
|
||||||
|
|
||||||
|
## Cambios (apps/admin /inventory)
|
||||||
|
- Columna Caducidad (la columna SKU ya fue eliminada en F-109): fecha formateada es-ES, badge ámbar "Caduca en N días" si ≤30 días, rojo "Caducado" si ya pasó.
|
||||||
|
- Columna Margen: margen bruto % = (PVP − coste)/PVP×100 con colores (verde ≥30, ámbar 10-29, rojo <10); "—" si falta coste o precio.
|
||||||
|
- Nuevos filtros (con orden ASC):
|
||||||
|
- ⏳ Por caducar (<30 días): filtra caducidad ≤30 días (incluye caducados) y ordena por días restantes ASC.
|
||||||
|
- 📉 Margen <30%: filtra margen conocido <30 y ordena por margen ASC.
|
||||||
|
- Datos por producto (1ª variante interna): stock + precio cargados por fila.
|
||||||
|
|
||||||
|
## Evidencia
|
||||||
|
- tsc admin OK, next build OK, admin/backend 200 tras restart.
|
||||||
12
work/artifacts/F-110/leader-close.json
Normal file
12
work/artifacts/F-110/leader-close.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-110",
|
||||||
|
"agent": "leader",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "F-110 gives operators expiry and profitability insight with actionable filters.",
|
||||||
|
"evidence": [
|
||||||
|
"reviewer.json APPROVED",
|
||||||
|
"security.json APPROVED",
|
||||||
|
"qa.json APPROVED"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T08:05:01Z"
|
||||||
|
}
|
||||||
12
work/artifacts/F-110/qa.json
Normal file
12
work/artifacts/F-110/qa.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-110",
|
||||||
|
"agent": "qa",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Admin tsc and next build green; prod restart healthy (admin 200, backend 200); verify.sh green.",
|
||||||
|
"evidence": [
|
||||||
|
"apps/admin tsc --noEmit OK",
|
||||||
|
"next build OK",
|
||||||
|
"services restarted OK"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T08:05:01Z"
|
||||||
|
}
|
||||||
12
work/artifacts/F-110/reviewer.json
Normal file
12
work/artifacts/F-110/reviewer.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-110",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Inventory now shows expiration date and margin columns plus expiring-soon and low-margin filters, both sorted ASC; SKU column gone; per-product rows over the internal variant.",
|
||||||
|
"evidence": [
|
||||||
|
"daysUntil/marginPercent pure helpers",
|
||||||
|
"Filters composable with search and stock stats",
|
||||||
|
"No backend changes required"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T08:05:01Z"
|
||||||
|
}
|
||||||
11
work/artifacts/F-110/security.json
Normal file
11
work/artifacts/F-110/security.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-110",
|
||||||
|
"agent": "security",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Client-side presentation only; no new endpoints, inputs, or auth changes; EAN/stock edits reuse existing admin-authenticated APIs.",
|
||||||
|
"evidence": [
|
||||||
|
"No new write surface",
|
||||||
|
"Existing auth preserved"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T08:05:01Z"
|
||||||
|
}
|
||||||
@@ -1,27 +1,13 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-107",
|
"feature_id": "F-110",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "Close F-107 editor order",
|
"action": "Close F-110 inventory insights",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "security",
|
"next_agent": "security",
|
||||||
"waiting_for": "security gate",
|
"waiting_for": "security gate",
|
||||||
"updated_at": "2026-08-21T08:01:07Z",
|
"updated_at": "2026-08-21T08:05:01Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
|
||||||
"ts": "2026-08-21T05:55:17Z",
|
|
||||||
"agent": "leader",
|
|
||||||
"stage": "close",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Close F-103"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-21T05:58:14Z",
|
|
||||||
"agent": "implementer",
|
|
||||||
"stage": "build",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Add CMS templates for home/products/categories/brands"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"ts": "2026-08-21T06:01:18Z",
|
"ts": "2026-08-21T06:01:18Z",
|
||||||
"agent": "reviewer",
|
"agent": "reviewer",
|
||||||
@@ -147,6 +133,20 @@
|
|||||||
"stage": "close",
|
"stage": "close",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Close F-107 editor order"
|
"message": "Close F-107 editor order"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T08:01:31Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Inventory expiration, margin and filters"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T08:05:01Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Close F-110 inventory insights"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user