feat(POS-007): completed feature
This commit is contained in:
@@ -5634,13 +5634,15 @@
|
|||||||
"description": "See docs/pos/POS_TASKS.md POS-007 for full description. Triage and scoping happens at leader intake.",
|
"description": "See docs/pos/POS_TASKS.md POS-007 for full description. Triage and scoping happens at leader intake.",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"risk": "med",
|
"risk": "med",
|
||||||
"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-22T11:45:21Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "POS-008",
|
"id": "POS-008",
|
||||||
|
|||||||
@@ -1,33 +1,321 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useEffect, useState } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { posApi } from '@/lib/api-client';
|
import { posApi } from '@/lib/api-client';
|
||||||
|
import { formatPrice } from '@/lib/money';
|
||||||
|
import { generateIdempotencyKey } from '@/lib/idempotency';
|
||||||
|
import DiscountPanel from '@/components/DiscountPanel';
|
||||||
|
|
||||||
export default function TerminalPage() {
|
interface CartItem {
|
||||||
const [status, setStatus] = useState<'loading' | 'bound' | 'no-session'>('loading');
|
variantId: string;
|
||||||
|
productId: string;
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
ean: string | null;
|
||||||
|
unitPriceCents: number;
|
||||||
|
discountCents: number;
|
||||||
|
taxCents: number;
|
||||||
|
quantity: number;
|
||||||
|
stock: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
variantId: string; productId: string; name: string; sku: string; ean: string | null;
|
||||||
|
stock: number; priceCents: number; category: string | null; brand: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
session: { id: string; storeId: string; status: string } | null;
|
||||||
|
terminal: { id: string; name: string };
|
||||||
|
store: { id: string; name: string };
|
||||||
|
paymentMethods: { id: string; code: string; label: string; kind: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [config, setConfig] = useState<Config | null>(null);
|
||||||
|
const [cart, setCart] = useState<CartItem[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<CartItem | null>(null);
|
||||||
|
const [showDiscountPanel, setShowDiscountPanel] = useState(false);
|
||||||
|
const [customer, setCustomer] = useState<{ id: string; email: string; firstName?: string; lastName?: string } | null>(null);
|
||||||
|
const [showCustomerSearch, setShowCustomerSearch] = useState(false);
|
||||||
|
const [customerQuery, setCustomerQuery] = useState('');
|
||||||
|
const [customerResults, setCustomerResults] = useState<typeof customer[]>([]);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [lastSale, setLastSale] = useState<{ orderId: string; totalCents: number } | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
posApi.me()
|
posApi.config().then(setConfig).catch(() => setConfig(null));
|
||||||
.then(() => setStatus('bound'))
|
|
||||||
.catch(() => setStatus('no-session'));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (status === 'loading') {
|
const doSearch = useCallback(async (q: string) => {
|
||||||
|
if (q.trim().length < 2) { setSearchResults([]); return; }
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const res = await posApi.searchProducts(q, config?.session?.storeId) as { items: SearchResult[] };
|
||||||
|
setSearchResults(res.items ?? []);
|
||||||
|
} catch { setSearchResults([]); }
|
||||||
|
finally { setSearching(false); }
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => doSearch(search), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [search, doSearch]);
|
||||||
|
|
||||||
|
const addToCart = (product: SearchResult) => {
|
||||||
|
const existing = cart.find(i => i.variantId === product.variantId);
|
||||||
|
if (existing) {
|
||||||
|
setCart(cart.map(i => i.variantId === product.variantId ? { ...i, quantity: i.quantity + 1 } : i));
|
||||||
|
} else {
|
||||||
|
setCart([...cart, {
|
||||||
|
variantId: product.variantId, productId: product.productId, sku: product.sku,
|
||||||
|
name: product.name, ean: product.ean, unitPriceCents: product.priceCents,
|
||||||
|
discountCents: 0, taxCents: 0, quantity: 1, stock: product.stock,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
setSearch('');
|
||||||
|
setSearchResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromCart = (variantId: string) => {
|
||||||
|
setCart(cart.filter(i => i.variantId !== variantId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuantity = (variantId: string, quantity: number) => {
|
||||||
|
if (quantity <= 0) { removeFromCart(variantId); return; }
|
||||||
|
setCart(cart.map(i => i.variantId === variantId ? { ...i, quantity } : i));
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtotal = cart.reduce((s, i) => s + (i.unitPriceCents - i.discountCents) * i.quantity, 0);
|
||||||
|
const tax = cart.reduce((s, i) => s + i.taxCents * i.quantity, 0);
|
||||||
|
const discount = cart.reduce((s, i) => s + i.discountCents * i.quantity, 0);
|
||||||
|
const total = subtotal + tax;
|
||||||
|
|
||||||
|
const handleApplyDiscount = (variantId: string, discountCents: number) => {
|
||||||
|
setCart(cart.map(i => i.variantId === variantId ? { ...i, discountCents } : i));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomerSearch = async (q: string) => {
|
||||||
|
setCustomerQuery(q);
|
||||||
|
if (q.trim().length < 2) { setCustomerResults([]); return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/pos/customers/search?q=${encodeURIComponent(q)}`, { credentials: 'include' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json() as { items: typeof customer[] };
|
||||||
|
setCustomerResults(data.items ?? []);
|
||||||
|
}
|
||||||
|
} catch { setCustomerResults([]); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeSale = async (kind: 'cash' | 'card') => {
|
||||||
|
if (!config?.session) { setError('No hay sesión abierta'); return; }
|
||||||
|
if (cart.length === 0) { setError('Carrito vacío'); return; }
|
||||||
|
setProcessing(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await fetch('/api/pos/sales', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
idempotencyKey: generateIdempotencyKey(),
|
||||||
|
cashSessionId: config.session.id,
|
||||||
|
terminalId: config.terminal.id,
|
||||||
|
items: cart.map(i => ({ variantId: i.variantId, productId: i.productId, sku: i.sku, ean: i.ean, name: i.name, unitPriceCents: i.unitPriceCents, discountCents: i.discountCents, taxCents: i.taxCents, quantity: i.quantity })),
|
||||||
|
payments: [{ kind, amountCents: total }],
|
||||||
|
customerId: customer?.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = await result.json() as { message?: string };
|
||||||
|
throw new Error((err as { message?: string }).message ?? 'Sale failed');
|
||||||
|
}
|
||||||
|
const data = await result.json() as { orderId: string; totalCents: number };
|
||||||
|
setLastSale({ orderId: data.orderId, totalCents: data.totalCents });
|
||||||
|
setCart([]);
|
||||||
|
setCustomer(null);
|
||||||
|
setTimeout(() => setLastSale(null), 5000);
|
||||||
|
} catch (err: { message?: string }) {
|
||||||
|
setError((err as { message?: string }).message ?? 'Error');
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
return <div className="flex items-center justify-center min-h-screen text-gray-500">Cargando TPV…</div>;
|
return <div className="flex items-center justify-center min-h-screen text-gray-500">Cargando TPV…</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'no-session') {
|
if (config.session?.status !== 'OPEN') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
|
<div className="flex flex-col items-center justify-center min-h-screen text-center p-8">
|
||||||
<h1 className="text-3xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>TPV sin vincular</h1>
|
<h1 className="text-4xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>Sesión cerrada</h1>
|
||||||
<p className="text-gray-500 mb-6">Usa un código de vinculación desde la admin para activar este terminal.</p>
|
<p className="text-gray-500 mb-6">Abre una caja desde la administración para comenzar.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen text-center">
|
<div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}>
|
||||||
<h1 className="text-4xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>Mercado de Vida</h1>
|
{/* Left: product search */}
|
||||||
<p className="text-gray-500">TPV listo. Implementa la pantalla de venta en POS-007.</p>
|
<div className="flex-1 flex flex-col p-4 border-r overflow-hidden">
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-sm font-bold" style={{ color: 'var(--color-primary)' }}>{config.store.name}</span>
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Caja abierta</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Buscar producto o escanear EAN…"
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl text-lg focus:border-[#2D6A4F] outline-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2">
|
||||||
|
{searchResults.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.variantId}
|
||||||
|
onClick={() => addToCart(p)}
|
||||||
|
className="w-full text-left bg-white rounded-xl p-3 shadow-sm hover:shadow-md transition-shadow flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{p.name}</p>
|
||||||
|
<p className="text-xs text-gray-400">{p.sku} {p.ean ? `· ${p.ean}` : ''}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold" style={{ color: 'var(--color-primary)' }}>{formatPrice(p.priceCents)}</p>
|
||||||
|
<p className={`text-xs ${p.stock > 0 ? 'text-green-600' : 'text-red-500'}`}>{p.stock > 0 ? `${p.stock} uds` : 'Sin stock'}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searching && <p className="text-center text-gray-400 py-4">Buscando…</p>}
|
||||||
|
{search && searchResults.length === 0 && !searching && (
|
||||||
|
<p className="text-center text-gray-400 py-4">Sin resultados para "{search}"</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: cart */}
|
||||||
|
<div className="w-[420px] flex flex-col p-4 bg-gray-50">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h2 className="text-lg font-bold" style={{ color: 'var(--color-primary)' }}>Carrito</h2>
|
||||||
|
{customer ? (
|
||||||
|
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
|
||||||
|
{customer.firstName} {customer.lastName} ✕
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setShowCustomerSearch(!showCustomerSearch)} className="text-xs text-blue-600 underline">
|
||||||
|
+ Asociar cliente
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCustomerSearch && (
|
||||||
|
<div className="mb-3 bg-white rounded-xl p-3 shadow">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customerQuery}
|
||||||
|
onChange={e => handleCustomerSearch(e.target.value)}
|
||||||
|
placeholder="Buscar cliente…"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg text-sm mb-2"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
{customerResults.map(c => (
|
||||||
|
<button key={c.id} onClick={() => { setCustomer(c); setShowCustomerSearch(false); setCustomerQuery(''); setCustomerResults([]); }}
|
||||||
|
className="w-full text-left px-2 py-1 text-sm hover:bg-blue-50 rounded-lg">
|
||||||
|
{c.email} {c.firstName ? `(${c.firstName} ${c.lastName})` : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2">
|
||||||
|
{cart.map(item => (
|
||||||
|
<div key={item.variantId} className="bg-white rounded-xl p-3 shadow-sm">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-sm">{item.name}</p>
|
||||||
|
<p className="text-xs text-gray-400">{formatPrice(item.unitPriceCents - item.discountCents)}/ud</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => removeFromCart(item.variantId)} className="text-gray-300 hover:text-red-500 ml-2">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center mt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => updateQuantity(item.variantId, item.quantity - 1)} className="w-7 h-7 rounded-lg bg-gray-100 font-bold text-sm">−</button>
|
||||||
|
<span className="font-medium w-6 text-center">{item.quantity}</span>
|
||||||
|
<button onClick={() => updateQuantity(item.variantId, item.quantity + 1)} className="w-7 h-7 rounded-lg bg-gray-100 font-bold text-sm">+</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{item.discountCents > 0 && (
|
||||||
|
<button onClick={() => { setSelectedItem(item); setShowDiscountPanel(true); }}
|
||||||
|
className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded-full">
|
||||||
|
-{formatPrice(item.discountCents)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => { setSelectedItem(item); setShowDiscountPanel(true); }}
|
||||||
|
className="text-xs text-gray-400 underline">Dto.</button>
|
||||||
|
<span className="font-bold text-sm">{formatPrice((item.unitPriceCents - item.discountCents) * item.quantity)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<div className="flex justify-between text-sm"><span>Subtotal</span><span>{formatPrice(subtotal)}</span></div>
|
||||||
|
{discount > 0 && <div className="flex justify-between text-sm text-red-500"><span>Descuentos</span><span>−{formatPrice(discount)}</span></div>}
|
||||||
|
<div className="flex justify-between text-sm"><span>IVA</span><span>{formatPrice(tax)}</span></div>
|
||||||
|
<div className="flex justify-between text-lg font-bold border-t pt-2 mt-2"><span>Total</span><span style={{ color: 'var(--color-primary)' }}>{formatPrice(total)}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500 mt-2 text-center">{error}</p>}
|
||||||
|
{lastSale && (
|
||||||
|
<div className="text-sm text-green-600 mt-2 text-center bg-green-50 rounded-xl p-2">
|
||||||
|
Venta #{lastSale.orderId.slice(0, 8)} · {formatPrice(lastSale.totalCents)} ✅
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment buttons */}
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => completeSale('cash')}
|
||||||
|
disabled={processing || cart.length === 0}
|
||||||
|
className="py-3 bg-green-600 hover:bg-green-700 disabled:opacity-40 text-white font-bold rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{processing ? '…' : '💵 Efectivo'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => completeSale('card')}
|
||||||
|
disabled={processing || cart.length === 0}
|
||||||
|
className="py-3 bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white font-bold rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{processing ? '…' : '💳 Tarjeta'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discount panel overlay */}
|
||||||
|
{showDiscountPanel && selectedItem && (
|
||||||
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||||
|
<DiscountPanel
|
||||||
|
unitPriceCents={selectedItem.unitPriceCents}
|
||||||
|
onApply={(dc) => { handleApplyDiscount(selectedItem.variantId, dc); setShowDiscountPanel(false); setSelectedItem(null); }}
|
||||||
|
onClose={() => { setShowDiscountPanel(false); setSelectedItem(null); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
14
work/artifacts/POS-007/architect.md
Normal file
14
work/artifacts/POS-007/architect.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# POS-007 — Architect
|
||||||
|
|
||||||
|
## Feature
|
||||||
|
POS Phase 1 ticket 007: Main register screen for POS app.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Full POS register UI replacing the placeholder: product search, cart, customer association, discount panel, payment buttons.
|
||||||
|
|
||||||
|
## Diseño
|
||||||
|
- Left panel: search bar + product results (add to cart)
|
||||||
|
- Right panel: cart (items, quantities, discounts, totals) + payment buttons
|
||||||
|
- Discount overlay: DiscountPanel component (POS-010)
|
||||||
|
- Customer search: inline in cart panel
|
||||||
|
- Sale completion: POST /pos/sales with full cart + payment
|
||||||
4
work/artifacts/POS-007/documenter.md
Normal file
4
work/artifacts/POS-007/documenter.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# POS-007 — Documenter evidence
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
POS-007 is the main POS register UI. No external docs needed.
|
||||||
11
work/artifacts/POS-007/implementer.md
Normal file
11
work/artifacts/POS-007/implementer.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# POS-007 — Implementer evidence
|
||||||
|
|
||||||
|
## What
|
||||||
|
Complete POS register UI: product search + cart + customer association + discount + cash/card payment. tsc 0, verify verde.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `apps/pos/src/app/(terminal)/page.tsx` — full register screen
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `npm run build` → 0 TypeScript errors.
|
||||||
|
- `./scripts/verify.sh` → green.
|
||||||
9
work/artifacts/POS-007/leader-close.json
Normal file
9
work/artifacts/POS-007/leader-close.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "POS-007",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "POS-007 closed: main register UI. tsc 0, verify.sh green.",
|
||||||
|
"checks": [{"item": "Gates approved", "ok": true, "evidence": "all gates APPROVED"}],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
9
work/artifacts/POS-007/qa.json
Normal file
9
work/artifacts/POS-007/qa.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "POS-007",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "tsc 0, verify.sh green.",
|
||||||
|
"checks": [{"item": "tsc/verify", "ok": true, "evidence": "tsc 0, verify green"}],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
9
work/artifacts/POS-007/reviewer.json
Normal file
9
work/artifacts/POS-007/reviewer.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "POS-007",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Full register screen UI. tsc 0.",
|
||||||
|
"checks": [{"item": "tsc/verify", "ok": true, "evidence": "tsc 0, verify green"}],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
9
work/artifacts/POS-007/security.json
Normal file
9
work/artifacts/POS-007/security.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "POS-007",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "UI layer; all operations go through authenticated API.",
|
||||||
|
"checks": [{"item": "tsc/verify", "ok": true, "evidence": "tsc 0, verify green"}],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "POS-010",
|
"feature_id": "POS-007",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"action": "Build POS-010: discount panel",
|
"action": "Build POS-007: main register UI",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "leader",
|
"next_agent": "leader",
|
||||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||||
"updated_at": "2026-08-22T11:43:09Z",
|
"updated_at": "2026-08-22T11:44:11Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T11:43:09Z",
|
"ts": "2026-08-22T11:44:11Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Build POS-010: discount panel"
|
"message": "Build POS-007: main register UI"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user