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.",
|
||||
"priority": "high",
|
||||
"risk": "med",
|
||||
"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-22T11:45:21Z"
|
||||
},
|
||||
{
|
||||
"id": "POS-008",
|
||||
|
||||
@@ -1,33 +1,321 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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() {
|
||||
const [status, setStatus] = useState<'loading' | 'bound' | 'no-session'>('loading');
|
||||
interface CartItem {
|
||||
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(() => {
|
||||
posApi.me()
|
||||
.then(() => setStatus('bound'))
|
||||
.catch(() => setStatus('no-session'));
|
||||
posApi.config().then(setConfig).catch(() => setConfig(null));
|
||||
}, []);
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
if (status === 'no-session') {
|
||||
if (config.session?.status !== 'OPEN') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
|
||||
<h1 className="text-3xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>TPV sin vincular</h1>
|
||||
<p className="text-gray-500 mb-6">Usa un código de vinculación desde la admin para activar este terminal.</p>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen text-center p-8">
|
||||
<h1 className="text-4xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>Sesión cerrada</h1>
|
||||
<p className="text-gray-500 mb-6">Abre una caja desde la administración para comenzar.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen text-center">
|
||||
<h1 className="text-4xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>Mercado de Vida</h1>
|
||||
<p className="text-gray-500">TPV listo. Implementa la pantalla de venta en POS-007.</p>
|
||||
<div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}>
|
||||
{/* Left: product search */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
"agent": "implementer",
|
||||
"action": "Build POS-010: discount panel",
|
||||
"action": "Build POS-007: main register UI",
|
||||
"state": "running",
|
||||
"next_agent": "leader",
|
||||
"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": [
|
||||
{
|
||||
"ts": "2026-08-22T11:43:09Z",
|
||||
"ts": "2026-08-22T11:44:11Z",
|
||||
"agent": "implementer",
|
||||
"stage": "build",
|
||||
"state": "running",
|
||||
"message": "Build POS-010: discount panel"
|
||||
"message": "Build POS-007: main register UI"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user