feat(F-110): completed feature
This commit is contained in:
@@ -4788,13 +4788,15 @@
|
||||
"Filter shows items with margin under 30 percent sorted ASC",
|
||||
"verify.sh is green"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"created_at": "2026-08-21",
|
||||
"gates": {
|
||||
"reviewer": false,
|
||||
"security": false,
|
||||
"qa": false
|
||||
}
|
||||
"reviewer": true,
|
||||
"security": true,
|
||||
"qa": true,
|
||||
"close": true
|
||||
},
|
||||
"completed_at": "2026-08-21T08:05:01Z"
|
||||
},
|
||||
{
|
||||
"id": "F-111",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { productsApi, inventoryApi } from '@/lib/api-client';
|
||||
import type { ProductVariant, StockAvailability } from '@/types';
|
||||
import { productsApi, inventoryApi, pricingApi } from '@/lib/api-client';
|
||||
import type { ProductVariant, StockAvailability, VariantPrice } from '@/types';
|
||||
|
||||
interface VariantRow {
|
||||
interface ProductRow {
|
||||
productId: string;
|
||||
productName: string;
|
||||
variant: ProductVariant;
|
||||
stock: StockAvailability | null;
|
||||
price: VariantPrice | null;
|
||||
expirationDate: string | null;
|
||||
loading: boolean;
|
||||
editing: boolean;
|
||||
editValue: string;
|
||||
@@ -18,13 +20,21 @@ interface VariantRow {
|
||||
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',
|
||||
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 }) {
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/** 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<VariantRow[]>([]);
|
||||
const [rows, setRows] = useState<ProductRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState<StockFilter>('all');
|
||||
const [filter, setFilter] = useState<RowFilter>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
// Load products + variants + stock
|
||||
// Load products + single internal variant + stock + price (F-108/F-110)
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
@@ -57,41 +101,38 @@ export default function InventoryPage() {
|
||||
q: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
const variantRows: VariantRow[] = [];
|
||||
const productRows: ProductRow[] = [];
|
||||
|
||||
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 variant = variants?.[0];
|
||||
if (!variant) continue;
|
||||
{
|
||||
variantRows.push({
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
variant,
|
||||
stock: null,
|
||||
loading: true,
|
||||
editing: false,
|
||||
editValue: '',
|
||||
saving: false,
|
||||
msg: '',
|
||||
editingEan: false,
|
||||
editEanValue: '',
|
||||
savingEan: false,
|
||||
});
|
||||
}
|
||||
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(variantRows);
|
||||
setRows(productRows);
|
||||
|
||||
// Load stock for each variant
|
||||
for (const vr of variantRows) {
|
||||
inventoryApi.getAvailability(vr.variant.id)
|
||||
for (const row of productRows) {
|
||||
inventoryApi.getAvailability(row.variant.id)
|
||||
.then((stock) => {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === vr.variant.id
|
||||
r.variant.id === row.variant.id
|
||||
? { ...r, stock, loading: false, editValue: String(stock.availableQuantity) }
|
||||
: r,
|
||||
),
|
||||
@@ -100,10 +141,20 @@ export default function InventoryPage() {
|
||||
.catch(() => {
|
||||
setRows((prev) =>
|
||||
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) {
|
||||
setError(e instanceof Error ? e.message : 'Error al cargar inventario');
|
||||
@@ -175,13 +226,33 @@ export default function InventoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Filter rows
|
||||
const filtered = rows.filter((r) => {
|
||||
if (filter === 'in_stock') return (r.stock?.availableQuantity ?? 0) >= 5;
|
||||
if (filter === 'low_stock') return (r.stock?.availableQuantity ?? 0) > 0 && (r.stock?.availableQuantity ?? 0) < 5;
|
||||
if (filter === 'out_of_stock') return (r.stock?.availableQuantity ?? 0) === 0;
|
||||
return true;
|
||||
});
|
||||
// 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) => {
|
||||
@@ -215,8 +286,8 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
|
||||
{/* Search + filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<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"
|
||||
@@ -231,8 +302,8 @@ export default function InventoryPage() {
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{(Object.keys(STOCK_LABELS) as StockFilter[]).map((f) => (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(Object.keys(FILTER_LABELS) as RowFilter[]).map((f) => (
|
||||
<button
|
||||
key={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'
|
||||
}`}
|
||||
>
|
||||
{STOCK_LABELS[f]}
|
||||
{FILTER_LABELS[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -279,105 +350,122 @@ export default function InventoryPage() {
|
||||
<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) => (
|
||||
<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">
|
||||
{row.loading ? (
|
||||
<span className="text-gray-300">—</span>
|
||||
) : row.editing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{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
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.editValue}
|
||||
onChange={(e) =>
|
||||
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, editValue: e.target.value }
|
||||
? { ...r, editing: true, editValue: String(r.stock?.availableQuantity ?? 0) }
|
||||
: 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>
|
||||
))}
|
||||
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>
|
||||
|
||||
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",
|
||||
"agent": "leader",
|
||||
"action": "Close F-107 editor order",
|
||||
"action": "Close F-110 inventory insights",
|
||||
"state": "running",
|
||||
"next_agent": "security",
|
||||
"waiting_for": "security gate",
|
||||
"updated_at": "2026-08-21T08:01:07Z",
|
||||
"updated_at": "2026-08-21T08:05:01Z",
|
||||
"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",
|
||||
"agent": "reviewer",
|
||||
@@ -147,6 +133,20 @@
|
||||
"stage": "close",
|
||||
"state": "running",
|
||||
"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