feat(F-163): completed feature

This commit is contained in:
chattie
2026-08-22 18:33:24 +02:00
parent 08af6caf50
commit 5d90dd32de
19 changed files with 403 additions and 37 deletions

View 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>
);
}

View File

@@ -42,6 +42,7 @@ export interface NavItem {
export const NAV_ITEMS: NavItem[] = [
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
{ href: '/reporting', label: 'Reporting', icon: '📈', permission: 'reporting.read' },
{ href: '/pos', label: 'TPV', icon: '🛒', permission: 'dashboard' },
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },

View File

@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -14,7 +14,7 @@ export default function LoginPage() {
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/login', {
const res = await fetch('/api/backoffice/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),

View File

@@ -43,6 +43,9 @@ export default function RegisterPage() {
const [needsBinding, setNeedsBinding] = useState(false);
const [bindingCode, setBindingCode] = useState('');
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 [search, setSearch] = useState('');
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) => {
if (q.trim().length < 2) { setSearchResults([]); return; }
setSearching(true);
@@ -231,9 +253,32 @@ export default function RegisterPage() {
if (config.session?.status !== 'OPEN') {
return (
<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 className="flex min-h-screen items-center justify-center bg-gray-100 p-6">
<form onSubmit={openCashSession} className="w-full max-w-md space-y-5 rounded-2xl bg-white p-8 shadow-lg">
<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>
);
}

View File

@@ -35,6 +35,12 @@ export const posApi = {
}),
/** Get POS config (store + terminal + payment methods + session status). */
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. */
searchProducts: (q: string, storeId?: string, limit = 20) =>
apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`),
@@ -46,6 +52,6 @@ export const posApi = {
export const authApi = {
login: (email: string, password: string) =>
apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
logout: () => apiFetch('/auth/logout', { method: 'POST' }),
apiFetch('/backoffice/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
logout: () => apiFetch('/backoffice/auth/logout', { method: 'POST' }),
};

View File

@@ -1,7 +1,7 @@
import { NextResponse } 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) {
const { pathname } = request.nextUrl;

View File

@@ -23,6 +23,9 @@ export interface PosRouteDeps {
}
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) {
const { pool, authenticate } = deps;
@@ -174,7 +177,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const user = await authenticate(request);
requireRole(user, 'admin');
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 ?? {},
);
// Generate a short binding code (8 hex chars)

View File

@@ -3,12 +3,14 @@ import type { PosPaymentMethodRepository } from '../infrastructure/pg-payment-me
import type { PosStore } from '../domain/store.js';
import type { PosTerminal } from '../domain/terminal.js';
import type { PosPaymentMethod } from '../infrastructure/pg-payment-method-repository.js';
import type { PosCashSession } from '../domain/cash-session.js';
export interface PosConfig {
store: PosStore;
terminal: PosTerminal;
paymentMethods: PosPaymentMethod[];
sessionOpen: boolean;
session: PosCashSession | null;
}
export class GetPosConfigUseCase {
@@ -29,6 +31,6 @@ export class GetPosConfigUseCase {
this.sessionRepo.findOpenByTerminal(terminalId),
]);
await this.terminalRepo.updateLastSeen(terminalId);
return { store, terminal, paymentMethods, sessionOpen: !!openSession };
return { store, terminal, paymentMethods, sessionOpen: !!openSession, session: openSession ?? null };
}
}