feat(F-163): completed feature
This commit is contained in:
@@ -6785,13 +6785,15 @@
|
|||||||
"description": "Clarify and expose terminal binding and cash session workflow so operator can configure and open TPV without manual database/API steps.",
|
"description": "Clarify and expose terminal binding and cash session workflow so operator can configure and open TPV without manual database/API steps.",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"risk": "med",
|
"risk": "med",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"created_at": "2026-08-22",
|
"created_at": "2026-08-22",
|
||||||
"gates": {
|
"gates": {
|
||||||
"reviewer": false,
|
"reviewer": true,
|
||||||
"security": false,
|
"security": true,
|
||||||
"qa": false
|
"qa": true,
|
||||||
}
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-22T16:33:24Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "F-164",
|
"id": "F-164",
|
||||||
@@ -6843,6 +6845,141 @@
|
|||||||
"close": true
|
"close": true
|
||||||
},
|
},
|
||||||
"completed_at": "2026-08-22T16:21:15Z"
|
"completed_at": "2026-08-22T16:21:15Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-167",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Reflow order editor into three columns",
|
||||||
|
"description": "Order edit layout uses three balanced columns: Actions/Products/Payment, Customer/Shipping and billing addresses, Shipping/Summary/History.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "med",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-168",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Reduce Reporting 30-day trend chart height",
|
||||||
|
"description": "Reporting dashboard 30-day trend occupies too much vertical space; reduce chart height responsively.",
|
||||||
|
"priority": "med",
|
||||||
|
"risk": "low",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-169",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Fix Reporting products Internal Server Error",
|
||||||
|
"description": "Diagnose and fix /reporting/products API/UI 500 with runtime regression coverage.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "high",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-170",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Allow deleting categories",
|
||||||
|
"description": "Categories admin delete action does not work; restore guarded deletion and clear error feedback.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "med",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-171",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Audit and fix all transactional email events",
|
||||||
|
"description": "Review every event that generates email, fix SMTP delivery, and verify test delivery to info@rikrdo.es.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "high",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-172",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Fix My Account login session loop",
|
||||||
|
"description": "Clicking My Account logs user out and redirects repeatedly to /auth/login; preserve storefront session and destination.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "med",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-173",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Fix checkout page load failure",
|
||||||
|
"description": "/checkout shows This page couldn't load; diagnose frontend/runtime and restore checkout.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "high",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-174",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Fix missing almagro.ttf font",
|
||||||
|
"description": "Resolve storefront 404 for almagro.ttf using correct local font asset/path or remove invalid reference.",
|
||||||
|
"priority": "med",
|
||||||
|
"risk": "low",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-175",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Use logo-main.webp in collapsed sidebar",
|
||||||
|
"description": "Collapsed admin sidebar must use logo-main.webp three-leaf favicon mark instead of logo-main.png.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "low",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
128
project/apps/admin/src/app/(dashboard)/pos/page.tsx
Normal file
128
project/apps/admin/src/app/(dashboard)/pos/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
|
||||||
|
interface PosStore { id: string; name: string; active: boolean }
|
||||||
|
interface PosTerminal {
|
||||||
|
id: string;
|
||||||
|
storeId: string;
|
||||||
|
name: string;
|
||||||
|
bindingCode: string | null;
|
||||||
|
boundAt: string | null;
|
||||||
|
status: string;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PosAdminPage() {
|
||||||
|
const [stores, setStores] = useState<PosStore[]>([]);
|
||||||
|
const [terminals, setTerminals] = useState<PosTerminal[]>([]);
|
||||||
|
const [storeId, setStoreId] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [newCode, setNewCode] = useState('');
|
||||||
|
const [copied, setCopied] = useState('');
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const [storeData, terminalData] = await Promise.all([
|
||||||
|
api.get<{ stores: PosStore[] }>('/api/pos/admin/stores'),
|
||||||
|
api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'),
|
||||||
|
]);
|
||||||
|
setStores(storeData.stores);
|
||||||
|
setTerminals(terminalData.terminals);
|
||||||
|
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');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
|
const createTerminal = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setCreating(true);
|
||||||
|
setError('');
|
||||||
|
setNewCode('');
|
||||||
|
try {
|
||||||
|
const terminal = await api.post<PosTerminal>('/api/pos/admin/terminals', { storeId, name: name.trim() });
|
||||||
|
setName('');
|
||||||
|
setNewCode(terminal.bindingCode ?? '');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'No se pudo crear el terminal');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyCode = async (code: string) => {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
setCopied(code);
|
||||||
|
setTimeout(() => setCopied(''), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">TPV</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-gray-500">Configura dispositivos y obtén el código para vincular cada caja.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-xl border border-blue-200 bg-blue-50 p-5 text-sm text-blue-900">
|
||||||
|
<h2 className="font-bold">1. Vincular terminal</h2>
|
||||||
|
<p className="mt-1">Crea un terminal aquí y escribe su código de 8 caracteres en el navegador del TPV. Esto identifica qué caja física está vendiendo.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900">
|
||||||
|
<h2 className="font-bold">2. Abrir sesión de caja</h2>
|
||||||
|
<p className="mt-1">Después de vincular, el cajero indica el efectivo inicial en el propio TPV. La sesión representa el turno diario de caja.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={createTerminal} className="rounded-xl border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="mb-4 font-bold text-gray-900">Crear terminal</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-[1fr_1fr_auto] md:items-end">
|
||||||
|
<label className="text-sm font-medium text-gray-700">
|
||||||
|
Tienda
|
||||||
|
<select value={storeId} onChange={(event) => setStoreId(event.target.value)} required className="mt-1 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5">
|
||||||
|
{stores.filter((store) => store.active).map((store) => <option key={store.id} value={store.id}>{store.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm font-medium text-gray-700">
|
||||||
|
Nombre del terminal
|
||||||
|
<input value={name} onChange={(event) => setName(event.target.value)} required maxLength={100} placeholder="Caja principal" className="mt-1 w-full rounded-xl border border-gray-300 px-3 py-2.5" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={creating || !storeId || !name.trim()} className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50">
|
||||||
|
{creating ? 'Creando…' : 'Crear terminal'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{newCode && <p className="mt-4 rounded-lg bg-green-50 p-3 text-sm text-green-800">Código creado: <strong className="font-mono text-lg tracking-widest">{newCode}</strong></p>}
|
||||||
|
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||||
|
<div className="border-b border-gray-200 px-6 py-4"><h2 className="font-bold text-gray-900">Terminales</h2></div>
|
||||||
|
{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>
|
||||||
|
<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>;
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export interface NavItem {
|
|||||||
export const NAV_ITEMS: NavItem[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
|
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
|
||||||
{ href: '/reporting', label: 'Reporting', icon: '📈', permission: 'reporting.read' },
|
{ href: '/reporting', label: 'Reporting', icon: '📈', permission: 'reporting.read' },
|
||||||
|
{ href: '/pos', label: 'TPV', icon: '🛒', permission: 'dashboard' },
|
||||||
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
|
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
|
||||||
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
|
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
|
||||||
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
|
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
|
||||||
|
|||||||
4
project/apps/pos/next-env.d.ts
vendored
4
project/apps/pos/next-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
import "./.next/dev/types/root-params.d.ts";
|
import "./.next/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function LoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch('/api/backoffice/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export default function RegisterPage() {
|
|||||||
const [needsBinding, setNeedsBinding] = useState(false);
|
const [needsBinding, setNeedsBinding] = useState(false);
|
||||||
const [bindingCode, setBindingCode] = useState('');
|
const [bindingCode, setBindingCode] = useState('');
|
||||||
const [binding, setBinding] = useState(false);
|
const [binding, setBinding] = useState(false);
|
||||||
|
const [openingCash, setOpeningCash] = useState('0');
|
||||||
|
const [openingSession, setOpeningSession] = useState(false);
|
||||||
|
const [sessionError, setSessionError] = useState('');
|
||||||
const [cart, setCart] = useState<CartItem[]>([]);
|
const [cart, setCart] = useState<CartItem[]>([]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||||
@@ -89,6 +92,25 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCashSession = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const amount = Number(openingCash.replace(',', '.'));
|
||||||
|
if (!Number.isFinite(amount) || amount < 0) {
|
||||||
|
setSessionError('Introduce un importe válido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpeningSession(true);
|
||||||
|
setSessionError('');
|
||||||
|
try {
|
||||||
|
await posApi.openSession(Math.round(amount * 100));
|
||||||
|
await loadConfig();
|
||||||
|
} catch (err) {
|
||||||
|
setSessionError(err instanceof Error ? err.message : 'No se pudo abrir la caja');
|
||||||
|
} finally {
|
||||||
|
setOpeningSession(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const doSearch = useCallback(async (q: string) => {
|
const doSearch = useCallback(async (q: string) => {
|
||||||
if (q.trim().length < 2) { setSearchResults([]); return; }
|
if (q.trim().length < 2) { setSearchResults([]); return; }
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
@@ -231,9 +253,32 @@ export default function RegisterPage() {
|
|||||||
|
|
||||||
if (config.session?.status !== 'OPEN') {
|
if (config.session?.status !== 'OPEN') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen text-center p-8">
|
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-6">
|
||||||
<h1 className="text-4xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>Sesión cerrada</h1>
|
<form onSubmit={openCashSession} className="w-full max-w-md space-y-5 rounded-2xl bg-white p-8 shadow-lg">
|
||||||
<p className="text-gray-500 mb-6">Abre una caja desde la administración para comenzar.</p>
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[#2D6A4F]">{config.store.name} · {config.terminal.name}</p>
|
||||||
|
<h1 className="mt-1 text-3xl font-bold text-gray-900">Abrir sesión de caja</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">Indica el efectivo disponible al comenzar el turno. Puedes usar 0 si la caja empieza vacía.</p>
|
||||||
|
</div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Efectivo inicial (€)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={openingCash}
|
||||||
|
onChange={(event) => setOpeningCash(event.target.value)}
|
||||||
|
className="mt-1 w-full rounded-xl border border-gray-300 px-4 py-3 text-xl outline-none focus:ring-2 focus:ring-[#2D6A4F]"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{sessionError && <p className="text-sm text-red-600">{sessionError}</p>}
|
||||||
|
<button type="submit" disabled={openingSession} className="w-full rounded-xl bg-[#2D6A4F] py-3 font-semibold text-white hover:bg-[#1B4332] disabled:opacity-50">
|
||||||
|
{openingSession ? 'Abriendo…' : 'Abrir caja y comenzar'}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-400">Una sesión de caja representa el turno diario y registra ventas, efectivo esperado y cierre.</p>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export const posApi = {
|
|||||||
}),
|
}),
|
||||||
/** Get POS config (store + terminal + payment methods + session status). */
|
/** Get POS config (store + terminal + payment methods + session status). */
|
||||||
config: <T>() => apiFetch<T>('/pos/config'),
|
config: <T>() => apiFetch<T>('/pos/config'),
|
||||||
|
/** Open the daily cash session for the bound terminal. */
|
||||||
|
openSession: (openingCashCents: number) =>
|
||||||
|
apiFetch<{ id: string; status: 'OPEN' }>('/pos/sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ openingCashCents }),
|
||||||
|
}),
|
||||||
/** List products by query. */
|
/** List products by query. */
|
||||||
searchProducts: (q: string, storeId?: string, limit = 20) =>
|
searchProducts: (q: string, storeId?: string, limit = 20) =>
|
||||||
apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`),
|
apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`),
|
||||||
@@ -46,6 +52,6 @@ export const posApi = {
|
|||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
login: (email: string, password: string) =>
|
login: (email: string, password: string) =>
|
||||||
apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
apiFetch('/backoffice/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||||
logout: () => apiFetch('/auth/logout', { method: 'POST' }),
|
logout: () => apiFetch('/backoffice/auth/logout', { method: 'POST' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const PUBLIC_PATHS = ['/login', '/api/auth'];
|
const PUBLIC_PATHS = ['/login', '/api/auth', '/api/backoffice/auth'];
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export interface PosRouteDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const idParamSchema = z.object({ id: z.string().uuid() });
|
const idParamSchema = z.object({ id: z.string().uuid() });
|
||||||
|
// Zod's strict UUID parser rejects the RFC nil UUID used by the seeded default
|
||||||
|
// store. Store references accept the canonical UUID shape, including nil.
|
||||||
|
const storeIdSchema = z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||||
|
|
||||||
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
|
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
|
||||||
const { pool, authenticate } = deps;
|
const { pool, authenticate } = deps;
|
||||||
@@ -174,7 +177,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
const user = await authenticate(request);
|
const user = await authenticate(request);
|
||||||
requireRole(user, 'admin');
|
requireRole(user, 'admin');
|
||||||
const body = parseJson(
|
const body = parseJson(
|
||||||
z.object({ storeId: z.string().uuid(), name: z.string().min(1).max(100) }),
|
z.object({ storeId: storeIdSchema, name: z.string().min(1).max(100) }),
|
||||||
request.body ?? {},
|
request.body ?? {},
|
||||||
);
|
);
|
||||||
// Generate a short binding code (8 hex chars)
|
// Generate a short binding code (8 hex chars)
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import type { PosPaymentMethodRepository } from '../infrastructure/pg-payment-me
|
|||||||
import type { PosStore } from '../domain/store.js';
|
import type { PosStore } from '../domain/store.js';
|
||||||
import type { PosTerminal } from '../domain/terminal.js';
|
import type { PosTerminal } from '../domain/terminal.js';
|
||||||
import type { PosPaymentMethod } from '../infrastructure/pg-payment-method-repository.js';
|
import type { PosPaymentMethod } from '../infrastructure/pg-payment-method-repository.js';
|
||||||
|
import type { PosCashSession } from '../domain/cash-session.js';
|
||||||
|
|
||||||
export interface PosConfig {
|
export interface PosConfig {
|
||||||
store: PosStore;
|
store: PosStore;
|
||||||
terminal: PosTerminal;
|
terminal: PosTerminal;
|
||||||
paymentMethods: PosPaymentMethod[];
|
paymentMethods: PosPaymentMethod[];
|
||||||
sessionOpen: boolean;
|
sessionOpen: boolean;
|
||||||
|
session: PosCashSession | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GetPosConfigUseCase {
|
export class GetPosConfigUseCase {
|
||||||
@@ -29,6 +31,6 @@ export class GetPosConfigUseCase {
|
|||||||
this.sessionRepo.findOpenByTerminal(terminalId),
|
this.sessionRepo.findOpenByTerminal(terminalId),
|
||||||
]);
|
]);
|
||||||
await this.terminalRepo.updateLastSeen(terminalId);
|
await this.terminalRepo.updateLastSeen(terminalId);
|
||||||
return { store, terminal, paymentMethods, sessionOpen: !!openSession };
|
return { store, terminal, paymentMethods, sessionOpen: !!openSession, session: openSession ?? null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
work/artifacts/F-163/architect.md
Normal file
10
work/artifacts/F-163/architect.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# F-163 — Diseño
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
Página `/pos` consume APIs administrativas existentes con el cliente `api` compartido. Lista terminales y permite crear uno para una tienda activa; el binding code se muestra/copiará. RBAC sigue backend admin-only.
|
||||||
|
|
||||||
|
## POS
|
||||||
|
Cuando config devuelve `session: null`, renderiza apertura de caja local. `POST /pos/sessions` recibe fondo inicial en céntimos; el proxy aporta `x-terminal-id` desde cookie. Tras 201 se recarga config.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
La tienda por defecto usa UUID nil válido; sustituir Zod `.uuid()` estricto del body de terminal por regex canónica que acepta UUID nil y generados.
|
||||||
7
work/artifacts/F-163/documenter.md
Normal file
7
work/artifacts/F-163/documenter.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# TPV: terminal y sesión
|
||||||
|
|
||||||
|
- **Terminal**: caja/dispositivo físico. Admin → TPV crea el terminal y muestra su código de 8 caracteres.
|
||||||
|
- **Vincular**: introducir ese código una vez en el navegador del TPV.
|
||||||
|
- **Sesión de caja**: turno diario. En el TPV se indica efectivo inicial y se pulsa «Abrir caja y comenzar».
|
||||||
|
|
||||||
|
La caja principal ya está vinculada y tiene una sesión OPEN.
|
||||||
10
work/artifacts/F-163/implementer.md
Normal file
10
work/artifacts/F-163/implementer.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# F-163 — Implementer
|
||||||
|
|
||||||
|
- Nueva entrada Admin → TPV y página para explicar, listar y crear terminales/códigos.
|
||||||
|
- Creación acepta UUID nil de tienda por defecto.
|
||||||
|
- POS login usa backoffice auth (cash session FK apunta a backoffice users).
|
||||||
|
- Pantalla «Sesión cerrada» sustituida por formulario para abrir caja con efectivo inicial.
|
||||||
|
- Config backend devuelve la sesión abierta completa, necesaria para ventas.
|
||||||
|
- Caja `TPV Principal` abierta con sesión `6d281efe-0682-4763-88bf-5d8cb4403c05`.
|
||||||
|
|
||||||
|
Evidencia: creación terminal con tienda default PASS; login/bind/open/config = 200/200/201/200; session OPEN; admin+POS builds PASS; 6/6 cash session tests; tsc/verify PASS.
|
||||||
1
work/artifacts/F-163/leader-close.json
Normal file
1
work/artifacts/F-163/leader-close.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"feature_id":"F-163","agent":"leader","stage":"close","verdict":"APPROVED","checks":[{"item":"all gates","ok":true},{"item":"e2e create-bind-open-config","ok":true},{"item":"build/tests/verify","ok":true}],"issues":[]}
|
||||||
1
work/artifacts/F-163/qa.json
Normal file
1
work/artifacts/F-163/qa.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"feature_id":"F-163","agent":"qa","stage":"qa_gate","verdict":"APPROVED","checks":[{"item":"create terminal","ok":true},{"item":"bind","ok":true},{"item":"open session","ok":true},{"item":"config session OPEN","ok":true},{"item":"build/tests","ok":true}],"issues":[]}
|
||||||
1
work/artifacts/F-163/reviewer.json
Normal file
1
work/artifacts/F-163/reviewer.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"feature_id":"F-163","agent":"reviewer","stage":"review_gate","verdict":"APPROVED","checks":[{"item":"admin terminal UI","ok":true},{"item":"cash opening UI","ok":true},{"item":"auth/FK alignment","ok":true},{"item":"runtime e2e","ok":true}],"issues":[]}
|
||||||
1
work/artifacts/F-163/security.json
Normal file
1
work/artifacts/F-163/security.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"feature_id":"F-163","agent":"security","stage":"security_gate","verdict":"APPROVED","checks":[{"item":"admin endpoints RBAC","ok":true},{"item":"HttpOnly terminal cookie","ok":true},{"item":"backoffice staff session","ok":true}],"issues":[]}
|
||||||
@@ -1,3 +1,16 @@
|
|||||||
# F-164 — Update outdated POS Next.js
|
# F-163 — Make POS terminal and cash session setup usable
|
||||||
|
|
||||||
Alinear POS con Admin: Next.js `16.3.1`, React/ReactDOM `19.2.8`. Actualizar lockfile, adaptar incompatibilidades de build y validar login/proxy/config.
|
## Qué significa
|
||||||
|
- **Vincular terminal**: asociar este navegador/dispositivo a una caja física mediante un código generado en Admin → TPV.
|
||||||
|
- **Sesión de caja**: turno diario de efectivo. Antes de vender se indica fondo inicial y se abre la caja.
|
||||||
|
|
||||||
|
## Implementación
|
||||||
|
- Nueva entrada/página Admin **TPV**: explicación, tiendas, terminales, creación y código de vinculación.
|
||||||
|
- Aceptar el UUID fijo de la tienda por defecto al crear terminal.
|
||||||
|
- POS muestra formulario para abrir caja cuando no hay sesión OPEN; deja de mandar al operador a una pantalla admin inexistente.
|
||||||
|
|
||||||
|
## Aceptación
|
||||||
|
1. Admin puede crear terminal y obtener código.
|
||||||
|
2. Dispositivo puede vincularse con ese código.
|
||||||
|
3. Cajero puede abrir sesión indicando efectivo inicial.
|
||||||
|
4. Tras abrir, aparece la pantalla de venta.
|
||||||
|
|||||||
@@ -1,68 +1,68 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-164",
|
"feature_id": "F-163",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "Close POS Next upgrade",
|
"action": "Close usable TPV setup",
|
||||||
"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-22T16:25:00Z",
|
"updated_at": "2026-08-22T16:33:09Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T16:21:23Z",
|
"ts": "2026-08-22T16:25:44Z",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"stage": "intake",
|
"stage": "intake",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Align POS Next.js with Admin supported version"
|
"message": "Make terminal binding and cash opening self-service"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T16:21:38Z",
|
"ts": "2026-08-22T16:26:32Z",
|
||||||
"agent": "architect",
|
"agent": "architect",
|
||||||
"stage": "design",
|
"stage": "design",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Plan POS framework version alignment"
|
"message": "Design Admin terminal setup and POS cash opening"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T16:21:52Z",
|
"ts": "2026-08-22T16:26:57Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Upgrade POS Next and React dependencies"
|
"message": "Implement terminal admin page and cash opening"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T16:23:59Z",
|
"ts": "2026-08-22T16:31:54Z",
|
||||||
"agent": "reviewer",
|
"agent": "reviewer",
|
||||||
"stage": "review_gate",
|
"stage": "review_gate",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Review POS framework migration"
|
"message": "Review end-to-end terminal and session setup"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T16:24:11Z",
|
"ts": "2026-08-22T16:32:07Z",
|
||||||
"agent": "security",
|
"agent": "security",
|
||||||
"stage": "security_gate",
|
"stage": "security_gate",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Audit upgraded dependencies"
|
"message": "Audit TPV admin RBAC and binding cookies"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T16:24:25Z",
|
"ts": "2026-08-22T16:32:29Z",
|
||||||
"agent": "qa",
|
"agent": "qa",
|
||||||
"stage": "qa_gate",
|
"stage": "qa_gate",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Validate upgraded POS build and LAN flow"
|
"message": "Validate create-bind-open-config workflow"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T16:24:41Z",
|
"ts": "2026-08-22T16:32:56Z",
|
||||||
"agent": "documenter",
|
"agent": "documenter",
|
||||||
"stage": "document",
|
"stage": "document",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Document POS framework upgrade"
|
"message": "Document terminal and cash session concepts"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T16:25:00Z",
|
"ts": "2026-08-22T16:33:09Z",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Close POS Next upgrade"
|
"message": "Close usable TPV setup"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user