feat(F-182): completed feature

This commit is contained in:
chattie
2026-08-22 19:25:12 +02:00
parent 2985d7274a
commit b860e8d1df
14 changed files with 361 additions and 25 deletions

View File

@@ -7108,13 +7108,15 @@
"description": "Build large touch buttons to navigate category to subcategory to product, plus four persistent easy-touch quick product slots at bottom",
"priority": "high",
"risk": "med",
"status": "pending",
"status": "done",
"created_at": "2026-08-22",
"gates": {
"reviewer": false,
"security": false,
"qa": false
}
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"completed_at": "2026-08-22T17:25:12Z"
},
{
"id": "F-183",

View File

@@ -12,7 +12,9 @@ interface PosTerminal {
boundAt: string | null;
status: string;
lastSeenAt: string | null;
settings?: { touchNavigationEnabled?: boolean; quickProductVariantIds?: Array<string | null> };
}
interface CatalogOption { variantId: string; productId: string; name: string; sku: string; priceCents: number }
export default function PosAdminPage() {
const [stores, setStores] = useState<PosStore[]>([]);
@@ -24,17 +26,25 @@ export default function PosAdminPage() {
const [error, setError] = useState('');
const [newCode, setNewCode] = useState('');
const [copied, setCopied] = useState('');
const [catalogOptions, setCatalogOptions] = useState<CatalogOption[]>([]);
const [configuring, setConfiguring] = useState<PosTerminal | null>(null);
const [touchEnabled, setTouchEnabled] = useState(true);
const [quickSlots, setQuickSlots] = useState<Array<string | null>>([null, null, null, null]);
const [savingTouch, setSavingTouch] = useState(false);
const [touchMessage, setTouchMessage] = useState('');
const load = async () => {
setLoading(true);
setError('');
try {
const [storeData, terminalData] = await Promise.all([
const [storeData, terminalData, catalogData] = await Promise.all([
api.get<{ stores: PosStore[] }>('/api/pos/admin/stores'),
api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'),
api.get<{ items: CatalogOption[] }>('/api/pos/admin/catalog-products'),
]);
setStores(storeData.stores);
setTerminals(terminalData.terminals);
setCatalogOptions(catalogData.items);
setStoreId((current) => current || storeData.stores.find((store) => store.active)?.id || '');
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar la configuración TPV');
@@ -62,6 +72,32 @@ export default function PosAdminPage() {
}
};
const openTouchConfig = (terminal: PosTerminal) => {
setConfiguring(terminal);
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
const configured = terminal.settings?.quickProductVariantIds ?? [];
setQuickSlots([0, 1, 2, 3].map((slot) => configured[slot] ?? null));
setTouchMessage('');
};
const saveTouchConfig = async () => {
if (!configuring) return;
setSavingTouch(true);
setTouchMessage('');
try {
await api.patch(`/api/pos/admin/terminals/${configuring.id}/touch-config`, {
touchNavigationEnabled: touchEnabled,
quickProductVariantIds: quickSlots,
});
setTouchMessage('Configuración táctil guardada');
await load();
} catch (err) {
setTouchMessage(err instanceof Error ? err.message : 'No se pudo guardar');
} finally {
setSavingTouch(false);
}
};
const copyCode = async (code: string) => {
await navigator.clipboard.writeText(code);
setCopied(code);
@@ -112,17 +148,48 @@ export default function PosAdminPage() {
{loading ? <p className="p-6 text-sm text-gray-500">Cargando</p> : terminals.length === 0 ? <p className="p-6 text-sm text-gray-500">No hay terminales.</p> : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500"><tr><th className="px-4 py-3">Nombre</th><th className="px-4 py-3">Tienda</th><th className="px-4 py-3">Código</th><th className="px-4 py-3">Estado</th><th className="px-4 py-3">Vinculado</th></tr></thead>
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500"><tr><th className="px-4 py-3">Nombre</th><th className="px-4 py-3">Tienda</th><th className="px-4 py-3">Código</th><th className="px-4 py-3">Estado</th><th className="px-4 py-3">Vinculado</th><th className="px-4 py-3">Interfaz</th></tr></thead>
<tbody className="divide-y divide-gray-100">
{terminals.map((terminal) => {
const store = stores.find((item) => item.id === terminal.storeId);
return <tr key={terminal.id}><td className="px-4 py-3 font-medium">{terminal.name}</td><td className="px-4 py-3">{store?.name ?? terminal.storeId}</td><td className="px-4 py-3"><button type="button" onClick={() => terminal.bindingCode && void copyCode(terminal.bindingCode)} disabled={!terminal.bindingCode} className="rounded-lg bg-gray-100 px-3 py-1 font-mono tracking-wider hover:bg-gray-200 disabled:opacity-40">{copied === terminal.bindingCode ? 'Copiado' : terminal.bindingCode ?? '—'}</button></td><td className="px-4 py-3 capitalize">{terminal.status}</td><td className="px-4 py-3">{terminal.boundAt ? 'Sí' : 'No'}</td></tr>;
return <tr key={terminal.id}><td className="px-4 py-3 font-medium">{terminal.name}</td><td className="px-4 py-3">{store?.name ?? terminal.storeId}</td><td className="px-4 py-3"><button type="button" onClick={() => terminal.bindingCode && void copyCode(terminal.bindingCode)} disabled={!terminal.bindingCode} className="rounded-lg bg-gray-100 px-3 py-1 font-mono tracking-wider hover:bg-gray-200 disabled:opacity-40">{copied === terminal.bindingCode ? 'Copiado' : terminal.bindingCode ?? '—'}</button></td><td className="px-4 py-3 capitalize">{terminal.status}</td><td className="px-4 py-3">{terminal.boundAt ? 'Sí' : 'No'}</td><td className="px-4 py-3"><button type="button" onClick={() => openTouchConfig(terminal)} className="rounded-lg border border-[#2D6A4F] px-3 py-1.5 text-xs font-semibold text-[#2D6A4F] hover:bg-green-50">Configurar táctil</button></td></tr>;
})}
</tbody>
</table>
</div>
)}
</div>
{configuring && (
<section className="rounded-xl border border-gray-200 bg-white p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="font-bold text-gray-900">Interfaz táctil · {configuring.name}</h2>
<p className="mt-1 text-sm text-gray-500">Las categorías y subcategorías se administran en Categorías. Aquí activas su navegación en este terminal y eliges cuatro productos rápidos.</p>
</div>
<button type="button" onClick={() => setConfiguring(null)} className="text-gray-400 hover:text-gray-700" aria-label="Cerrar"></button>
</div>
<label className="mt-5 flex items-center gap-3 rounded-xl bg-gray-50 p-4 text-sm font-medium text-gray-800">
<input type="checkbox" checked={touchEnabled} onChange={(event) => setTouchEnabled(event.target.checked)} className="h-5 w-5 accent-[#2D6A4F]" />
Activar botones grandes categoría subcategoría producto
</label>
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{quickSlots.map((variantId, slot) => (
<label key={slot} className="text-sm font-medium text-gray-700">
Producto rápido {slot + 1}
<select value={variantId ?? ''} onChange={(event) => setQuickSlots((current) => current.map((value, index) => index === slot ? event.target.value || null : value))} className="mt-1 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5">
<option value="">Sin asignar</option>
{catalogOptions.map((product) => <option key={product.variantId} value={product.variantId}>{product.name} · {product.sku}</option>)}
</select>
</label>
))}
</div>
<div className="mt-5 flex items-center gap-4">
<button type="button" onClick={() => void saveTouchConfig()} disabled={savingTouch} className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50">{savingTouch ? 'Guardando…' : 'Guardar interfaz táctil'}</button>
{touchMessage && <p className={`text-sm ${touchMessage.includes('guardada') ? 'text-green-700' : 'text-red-600'}`}>{touchMessage}</p>}
</div>
</section>
)}
</div>
);
}

View File

@@ -20,12 +20,29 @@ interface CartItem {
interface SearchResult {
variantId: string; productId: string; name: string; sku: string; ean: string | null;
stock: number; priceCents: number; category: string | null; brand: string | null;
stock: number; priceCents: number; category?: string | null; brand?: string | null;
categoryId?: string | null;
}
interface TouchCategory {
id: string;
parentId: string | null;
name: string;
emoji: string | null;
bgColor: string | null;
textColor: string | null;
}
interface TouchCatalog {
enabled: boolean;
categories: TouchCategory[];
products: SearchResult[];
quickProducts: Array<SearchResult | null>;
}
interface Config {
session: { id: string; storeId: string; status: string } | null;
terminal: { id: string; name: string };
terminal: { id: string; name: string; settings?: Record<string, unknown> };
store: { id: string; name: string };
paymentMethods: { id: string; code: string; label: string; kind: string }[];
}
@@ -51,6 +68,8 @@ export default function RegisterPage() {
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState('');
const [touchCatalog, setTouchCatalog] = useState<TouchCatalog | null>(null);
const [categoryPath, setCategoryPath] = useState<TouchCategory[]>([]);
const [selectedItem, setSelectedItem] = useState<CartItem | null>(null);
const [showDiscountPanel, setShowDiscountPanel] = useState(false);
const [customer, setCustomer] = useState<Customer | null>(null);
@@ -82,6 +101,16 @@ export default function RegisterPage() {
void loadConfig();
}, [loadConfig]);
useEffect(() => {
if (!config?.session || config.session.status !== 'OPEN') return;
void posApi.touchCatalog<TouchCatalog>()
.then((catalog) => {
setTouchCatalog(catalog);
setCategoryPath([]);
})
.catch(() => setTouchCatalog(null));
}, [config?.session?.id, config?.session?.status]);
const bindTerminal = async (event: React.FormEvent) => {
event.preventDefault();
setBinding(true);
@@ -266,6 +295,14 @@ export default function RegisterPage() {
}
};
const currentCategory = categoryPath[categoryPath.length - 1] ?? null;
const visibleCategories = (touchCatalog?.categories ?? []).filter(
(category) => category.parentId === (currentCategory?.id ?? null),
);
const visibleTouchProducts = currentCategory
? (touchCatalog?.products ?? []).filter((product) => product.categoryId === currentCategory.id)
: [];
if (!config && needsBinding) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-6">
@@ -368,6 +405,55 @@ export default function RegisterPage() {
/>
</div>
{!search.trim() && touchCatalog?.enabled && (
<div className="flex-1 overflow-y-auto pb-3">
<div className="mb-3 flex items-center gap-2">
<button
type="button"
disabled={categoryPath.length === 0}
onClick={() => setCategoryPath((path) => path.slice(0, -1))}
className="min-h-12 rounded-xl border border-gray-200 px-4 text-sm font-semibold text-gray-700 disabled:opacity-30"
>
Atrás
</button>
<div className="min-w-0 text-sm text-gray-500">
<button type="button" onClick={() => setCategoryPath([])} className="font-semibold text-[#2D6A4F]">Categorías</button>
{categoryPath.map((category, index) => (
<span key={category.id}> <span aria-hidden="true"></span> <button type="button" onClick={() => setCategoryPath((path) => path.slice(0, index + 1))} className="hover:underline">{category.name}</button></span>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{visibleCategories.map((category) => (
<button
key={category.id}
type="button"
onClick={() => setCategoryPath((path) => [...path, category])}
className="min-h-24 rounded-2xl border border-black/5 p-4 text-left text-lg font-bold shadow-sm transition-transform active:scale-95"
style={{ backgroundColor: category.bgColor || '#eef7e8', color: category.textColor || '#2D6A4F' }}
>
<span className="mr-2 text-2xl" aria-hidden="true">{category.emoji || '📁'}</span>
{category.name}
</button>
))}
{visibleTouchProducts.map((product) => (
<button
key={`${currentCategory?.id}-${product.variantId}`}
type="button"
onClick={() => addToCart(product)}
className="min-h-24 rounded-2xl border-2 border-gray-100 bg-white p-4 text-left shadow-sm transition-transform active:scale-95"
>
<span className="block text-base font-bold text-gray-900">{product.name}</span>
<span className="mt-2 flex justify-between text-sm"><span className="text-gray-500">{product.stock} uds</span><strong className="text-[#2D6A4F]">{formatPrice(product.priceCents)}</strong></span>
</button>
))}
</div>
{currentCategory && visibleCategories.length === 0 && visibleTouchProducts.length === 0 && (
<p className="py-8 text-center text-gray-400">No hay productos activos en esta categoría.</p>
)}
</div>
)}
{searchResults.length > 0 && (
<div className="flex-1 overflow-y-auto space-y-2">
{searchResults.map(p => (
@@ -394,6 +480,19 @@ export default function RegisterPage() {
{search && searchResults.length === 0 && !searching && !searchError && (
<p className="text-center text-gray-400 py-4">Sin resultados para &quot;{search}&quot;</p>
)}
{!search.trim() && touchCatalog?.enabled && (
<div className="grid shrink-0 grid-cols-4 gap-2 border-t border-gray-200 pt-3" aria-label="Productos rápidos">
{touchCatalog.quickProducts.map((product, slot) => product ? (
<button key={product.variantId} type="button" onClick={() => addToCart(product)} className="min-h-20 rounded-xl bg-[#2D6A4F] px-2 py-2 text-sm font-bold text-white shadow active:scale-95">
<span className="line-clamp-2">{product.name}</span>
<span className="mt-1 block text-xs font-medium text-white/80">{formatPrice(product.priceCents)}</span>
</button>
) : (
<div key={slot} className="flex min-h-20 items-center justify-center rounded-xl border-2 border-dashed border-gray-200 text-xs text-gray-400">Rápido {slot + 1}</div>
))}
</div>
)}
</div>
{/* Right: cart */}

View File

@@ -41,6 +41,8 @@ export const posApi = {
method: 'POST',
body: JSON.stringify({ openingCashCents }),
}),
/** Load touch category navigation and four terminal quick products. */
touchCatalog: <T>() => apiFetch<T>('/pos/catalog/touch'),
/** List products by query. */
searchProducts: (q: string, storeId?: string, limit = 20) =>
apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`),

View File

@@ -197,6 +197,94 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
},
);
app.get(
'/pos/admin/catalog-products',
{
schema: {
tags: ['POS Admin'],
summary: 'List products available for TPV touch configuration',
response: { 401: errorSchema, 403: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const result = await pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
WHERE p.state = 'active'
ORDER BY p.name, v.sku`,
);
return reply.send({ items: result.rows });
},
);
app.patch<{ Params: { id: string } }>(
'/pos/admin/terminals/:id/touch-config',
{
schema: {
tags: ['POS Admin'],
summary: 'Configure terminal touch navigation and quick products',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['touchNavigationEnabled', 'quickProductVariantIds'],
properties: {
touchNavigationEnabled: { type: 'boolean' },
quickProductVariantIds: {
type: 'array', minItems: 4, maxItems: 4,
items: { type: ['string', 'null'], format: 'uuid' },
},
},
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const body = parseJson(
z.object({
touchNavigationEnabled: z.boolean(),
quickProductVariantIds: z.array(z.string().uuid().nullable()).length(4),
}),
request.body ?? {},
);
const selected = body.quickProductVariantIds.filter((value): value is string => value !== null);
if (new Set(selected).size !== selected.length) {
throw new AppError(400, 'DUPLICATE_QUICK_PRODUCT', 'Cada acceso rápido debe usar un producto diferente');
}
if (selected.length > 0) {
const valid = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
FROM catalog_product_variants v JOIN catalog_products p ON p.id = v.product_id
WHERE v.id = ANY($1::uuid[]) AND p.state = 'active'`,
[selected],
);
if (Number(valid.rows[0]?.count ?? 0) !== selected.length) {
throw new AppError(400, 'INVALID_QUICK_PRODUCT', 'Algún producto rápido no está activo');
}
}
const result = await pool.query(
`UPDATE pos_terminals
SET interface_mode = CASE WHEN $2 THEN 'touch' ELSE 'auto' END,
settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object(
'touchNavigationEnabled', $2::boolean,
'quickProductVariantIds', $3::jsonb
),
updated_at = now()
WHERE id = $1 RETURNING *`,
[id, body.touchNavigationEnabled, JSON.stringify(body.quickProductVariantIds)],
);
if (!result.rows[0]) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
return reply.send({ ok: true });
},
);
app.get<{ Params: { id: string } }>(
'/pos/admin/terminals/:id',
{
@@ -424,6 +512,71 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
},
);
app.get(
'/pos/catalog/touch',
{
schema: {
tags: ['POS Terminal'],
summary: 'Get category navigation and quick products for the bound terminal',
headers: {
type: 'object', required: ['x-terminal-id'],
properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } },
},
response: { 401: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const terminalId = request.headers['x-terminal-id'] as string | undefined;
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
const terminalResult = await pool.query<{ store_id: string; settings: Record<string, unknown> }>(
`SELECT store_id, settings FROM pos_terminals WHERE id = $1 AND status = 'active'`,
[terminalId],
);
const terminal = terminalResult.rows[0];
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
const [categoryResult, productResult] = await Promise.all([
pool.query(
`SELECT id, parent_id AS "parentId", name, emoji, bg_color AS "bgColor", text_color AS "textColor"
FROM categories_categories ORDER BY name`,
),
pool.query(
`SELECT v.id AS "variantId", p.id AS "productId", p.name, v.sku, v.ean,
pc.category_id AS "categoryId", COALESCE(stock.quantity, 0) AS stock,
COALESCE(pp.offer_cents, pp.net_unit_amount_cents, 0) AS "priceCents"
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(available), 0)::int AS quantity
FROM inventory_stock WHERE variant_id = v.id AND store_id = $1
) stock ON true
LEFT JOIN pricing_variant_prices pp ON pp.variant_id = v.id AND pp.currency = 'EUR'
WHERE p.state = 'active'
ORDER BY p.name, v.sku`,
[terminal.store_id],
),
]);
const configured = Array.isArray(terminal.settings?.quickProductVariantIds)
? terminal.settings.quickProductVariantIds
: [null, null, null, null];
const uniqueProducts = new Map<string, Record<string, unknown>>();
for (const row of productResult.rows as Array<Record<string, unknown>>) {
if (!uniqueProducts.has(String(row.variantId))) uniqueProducts.set(String(row.variantId), row);
}
return reply.send({
enabled: terminal.settings?.touchNavigationEnabled !== false,
categories: categoryResult.rows,
products: productResult.rows,
quickProducts: [0, 1, 2, 3].map((slot) => {
const variantId = configured[slot];
return typeof variantId === 'string' ? uniqueProducts.get(variantId) ?? null : null;
}),
});
},
);
// ── POS-005: Product search ───────────────────────────────────────────────
app.get(

View File

@@ -0,0 +1,3 @@
# F-182
Persist touch config in pos_terminals.settings. Category hierarchy remains sourced from Admin Categories/product assignments. Terminal endpoint returns touch catalogue and hydrated four quick slots.

View File

@@ -0,0 +1,3 @@
# Interfaz táctil TPV
En Admin → TPV pulsa «Configurar táctil» en un terminal. Activa la navegación y elige hasta cuatro productos rápidos. Categorías, subcategorías y asignaciones de productos se gestionan desde los módulos Catálogo/Categorías. En el TPV, los botones grandes añaden una unidad al carrito; los cuatro accesos rápidos permanecen abajo.

View File

@@ -0,0 +1,3 @@
# F-182
Admin → TPV now loads active catalogue options and configures touch mode plus four per-terminal quick slots in `pos_terminals.settings`. New RBAC endpoints validate distinct active variants and return a store-stock-aware touch catalogue. POS shows large category → subcategory → product buttons, breadcrumb/back controls, and four persistent finger-sized quick buttons; search remains available. Runtime: 12 options, config PATCH 200, touch catalog 200 with 10 categories/13 category-product rows/four hydrated quick products. Admin/POS builds and backend typecheck pass.

View File

@@ -0,0 +1 @@
{"feature_id":"F-182","agent":"leader","stage":"close","verdict":"APPROVED","checks":[{"item":"all gates/runtime/builds/verify","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-182","agent":"qa","stage":"qa_gate","verdict":"APPROVED","checks":[{"item":"config PATCH 200","ok":true},{"item":"catalog GET 200","ok":true},{"item":"four hydrated quick products","ok":true},{"item":"category hierarchy and store stock returned","ok":true},{"item":"Admin/POS builds/typecheck","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-182","agent":"reviewer","stage":"review_gate","verdict":"APPROVED","checks":[{"item":"Admin-owned terminal config","ok":true},{"item":"category/subcategory/product touch flow","ok":true},{"item":"exactly four quick slots","ok":true},{"item":"runtime/builds","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-182","agent":"security","stage":"security_gate","verdict":"APPROVED","checks":[{"item":"Admin RBAC for config","ok":true},{"item":"staff RBAC and bound terminal for catalog","ok":true},{"item":"active distinct variant validation","ok":true},{"item":"parameterized SQL","ok":true}],"issues":[]}

View File

@@ -1,3 +1,3 @@
# F-171 — Transactional email audit
# F-182 — Touch category navigation and quick products
Ensure account welcome, password reset, payment success/order status, payment failure and generic notification dispatch all use SMTP settings. Add a real settings-backed notifications provider and verify representative messages to info@rikrdo.es.
Admin → TPV configures touch mode and exactly four optional quick-product slots per terminal. POS derives large category/subcategory/product buttons from the managed catalogue and keeps four bottom quick slots. Search remains available.

View File

@@ -1,64 +1,64 @@
{
"feature_id": "F-171",
"feature_id": "F-182",
"stage": "close",
"agent": "leader",
"action": "close",
"state": "running",
"next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-22T17:20:34Z",
"updated_at": "2026-08-22T17:25:12Z",
"timeline": [
{
"ts": "2026-08-22T17:18:38Z",
"ts": "2026-08-22T17:21:28Z",
"agent": "leader",
"stage": "intake",
"state": "running",
"message": "Audit all transactional email delivery"
"message": "Build Admin-configured touch product navigation"
},
{
"ts": "2026-08-22T17:18:38Z",
"ts": "2026-08-22T17:21:28Z",
"agent": "architect",
"stage": "design",
"state": "running",
"message": "Centralize settings-backed SMTP delivery"
"message": "Design terminal settings and touch catalogue endpoint"
},
{
"ts": "2026-08-22T17:18:38Z",
"ts": "2026-08-22T17:21:28Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Implement SMTP provider and missing failure event"
"message": "Implement Admin config and touch register"
},
{
"ts": "2026-08-22T17:20:33Z",
"ts": "2026-08-22T17:25:12Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "running",
"message": "review"
},
{
"ts": "2026-08-22T17:20:33Z",
"ts": "2026-08-22T17:25:12Z",
"agent": "security",
"stage": "security_gate",
"state": "running",
"message": "security"
},
{
"ts": "2026-08-22T17:20:33Z",
"ts": "2026-08-22T17:25:12Z",
"agent": "qa",
"stage": "qa_gate",
"state": "running",
"message": "qa"
},
{
"ts": "2026-08-22T17:20:33Z",
"ts": "2026-08-22T17:25:12Z",
"agent": "documenter",
"stage": "document",
"state": "running",
"message": "document"
},
{
"ts": "2026-08-22T17:20:34Z",
"ts": "2026-08-22T17:25:12Z",
"agent": "leader",
"stage": "close",
"state": "running",