feat(POS-006): completed feature

This commit is contained in:
chattie
2026-08-22 13:39:11 +02:00
parent 915fbe0ba9
commit 25e4e5b622
24 changed files with 448 additions and 62 deletions

View File

@@ -0,0 +1,10 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
port: 3006,
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000',
},
};
export default nextConfig;

View File

@@ -0,0 +1,24 @@
{
"name": "mercadodevida-pos",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3006",
"build": "next build",
"start": "next start --port 3006"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"tailwindcss": "^4.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
}
}

View File

@@ -0,0 +1,76 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include',
});
if (res.ok) {
router.push('/');
} else {
const data = await res.json() as { message?: string };
setError(data.message ?? 'Login failed');
}
} catch {
setError('Network error');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-sm">
<h1 className="text-2xl font-bold text-center mb-6" style={{ color: 'var(--color-primary)' }}>
Mercado de Vida
</h1>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#2D6A4F] outline-none"
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#2D6A4F] outline-none"
required
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white font-semibold rounded-xl transition-colors"
>
{loading ? 'Entrando…' : 'Entrar'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
import type { NextLayout } from 'next';
const TerminalLayout: NextLayout = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
export default TerminalLayout;

View File

@@ -0,0 +1,33 @@
'use client';
import { useEffect, useState } from 'react';
import { posApi } from '@/lib/api-client';
export default function TerminalPage() {
const [status, setStatus] = useState<'loading' | 'bound' | 'no-session'>('loading');
useEffect(() => {
posApi.me()
.then(() => setStatus('bound'))
.catch(() => setStatus('no-session'));
}, []);
if (status === 'loading') {
return <div className="flex items-center justify-center min-h-screen text-gray-500">Cargando TPV</div>;
}
if (status === 'no-session') {
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>
);
}
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>
);
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
export async function GET(request: NextRequest) {
const path = request.nextUrl.pathname.replace('/api/', '');
const search = request.nextUrl.search;
const cookie = request.headers.get('cookie') ?? '';
const terminalId = request.headers.get('x-terminal-id');
const headers: Record<string, string> = { Cookie: cookie };
if (terminalId) headers['x-terminal-id'] = terminalId;
const res = await fetch(`${API}/${path}${search}`, { headers, credentials: 'include' });
const body = await res.text();
return new NextResponse(body, { status: res.status, headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' } });
}
export async function POST(request: NextRequest) {
const path = request.nextUrl.pathname.replace('/api/', '');
const search = request.nextUrl.search;
const cookie = request.headers.get('cookie') ?? '';
const terminalId = request.headers.get('x-terminal-id');
const body = await request.text();
const headers: Record<string, string> = { 'Content-Type': 'application/json', Cookie: cookie };
if (terminalId) headers['x-terminal-id'] = terminalId;
const res = await fetch(`${API}/${path}${search}`, { method: 'POST', headers, body, credentials: 'include' });
const resBody = await res.text();
return new NextResponse(resBody, { status: res.status, headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' } });
}

View File

@@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-primary: #2D6A4F;
--color-primary-dark: #1B4332;
--color-bg: #f9fafb;
--color-surface: #ffffff;
--color-border: #e5e7eb;
--color-text: #111827;
--color-text-muted: #6b7280;
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Mercado de Vida — TPV',
description: 'Terminal punto de venta',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,40 @@
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw Object.assign(new Error((err as { message?: string }).message ?? 'Request failed'), { status: res.status });
}
return res.json() as Promise<T>;
}
export const posApi = {
/** Get current terminal info. Requires x-terminal-id header set by middleware. */
me: () => apiFetch('/pos/terminals/me'),
/** Bind terminal with code. */
bind: (bindingCode: string) =>
apiFetch('/pos/terminals/bind', { method: 'POST', body: JSON.stringify({ bindingCode }) }),
/** Get POS config (store + terminal + payment methods + session status). */
config: () => apiFetch('/pos/config'),
/** List products by query. */
searchProducts: (q: string, storeId?: string, limit = 20) =>
apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`),
/** Get product by EAN. */
productByEan: (ean: string) => apiFetch(`/pos/products/by-ean/${encodeURIComponent(ean)}`),
/** Get product by SKU. */
productBySku: (sku: string) => apiFetch(`/pos/products/by-sku/${encodeURIComponent(sku)}`),
};
export const authApi = {
login: (email: string, password: string) =>
apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
logout: () => apiFetch('/auth/logout', { method: 'POST' }),
};

View File

@@ -0,0 +1,12 @@
/** Generate a client-side idempotency key (UUID v4). */
export function generateIdempotencyKey(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

View File

@@ -0,0 +1,4 @@
/** Format cents as EUR string. */
export function formatPrice(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}

View File

@@ -0,0 +1,15 @@
/** POS permissions used in the POS app. */
export type PosPermission =
| 'POS_SELL'
| 'POS_REFUND'
| 'POS_DISCOUNT'
| 'POS_VOID'
| 'POS_OPEN_REGISTER'
| 'POS_CLOSE_REGISTER';
/** Map role → granted permissions. */
export const POS_ROLE_PERMISSIONS: Record<string, PosPermission[]> = {
admin: ['POS_SELL', 'POS_REFUND', 'POS_DISCOUNT', 'POS_VOID', 'POS_OPEN_REGISTER', 'POS_CLOSE_REGISTER'],
pos_manager: ['POS_SELL', 'POS_REFUND', 'POS_DISCOUNT', 'POS_VOID', 'POS_OPEN_REGISTER', 'POS_CLOSE_REGISTER'],
pos_cashier: ['POS_SELL'],
};

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const PUBLIC_PATHS = ['/login', '/api/auth'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public paths
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
// Check for backoffice session cookie
const cookie = request.headers.get('cookie') ?? '';
const hasSession = cookie.includes('backoffice_session') || cookie.includes('session_token');
if (!hasSession) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

View File

@@ -0,0 +1,11 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
export default config;

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "next.config.ts"],
"exclude": ["node_modules"]
}