feat(POS-FIX-6): completed feature

This commit is contained in:
chattie
2026-08-24 15:40:01 +02:00
parent 3cb7325a42
commit 0dcb0f63a0
19 changed files with 687 additions and 233 deletions

View File

@@ -7554,7 +7554,7 @@
"qa": true, "qa": true,
"close": true "close": true
}, },
"completed_at": "2026-08-23T07:02:05Z" "completed_at": "2026-08-24T13:40:01Z"
}, },
{ {
"id": "F-201", "id": "F-201",

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
allowedDevOrigins: ['192.168.18.93', 'localhost'], allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'admin-mv.rikrdo.com'],
// Keep Turbopack rooted at this app. The repository also contains the // Keep Turbopack rooted at this app. The repository also contains the
// legacy project/frontend/package-lock.json; without an explicit root, // legacy project/frontend/package-lock.json; without an explicit root,
// Next.js 16 may infer the wrong workspace during production builds. // Next.js 16 may infer the wrong workspace during production builds.

View File

@@ -3,9 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --port 3004", "dev": "next dev --port 3001",
"build": "next build", "build": "next build",
"start": "next start --port 3004", "start": "next start --port 3001",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },

View File

@@ -153,7 +153,7 @@ export default function OrdersPage() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="bg-gray-50 border-b border-gray-200"> <tr className="bg-gray-50 border-b border-gray-200">
{['ID', 'Fecha', 'Total', 'Estado'].map((h) => ( {['ID', 'Fecha', 'Cliente', 'Total', 'Estado'].map((h) => (
<th <th
key={h} key={h}
className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3" className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3"
@@ -183,6 +183,10 @@ export default function OrdersPage() {
})} })}
</p> </p>
</td> </td>
<td className="px-4 py-3">
<p className="text-sm text-gray-600">{o.email || o.customerName || '—'}</p>
{o.email && <p className="text-xs text-gray-400">{o.email}</p>}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="text-sm font-bold text-gray-900">{formatPrice(o.totalCents)}</p> <p className="text-sm font-bold text-gray-900">{formatPrice(o.totalCents)}</p>
</td> </td>

View File

@@ -7,6 +7,14 @@ interface LogEntry {
levelName: string; levelName: string;
msg: string; msg: string;
err?: Record<string, unknown>; err?: Record<string, unknown>;
method?: string;
url?: string;
statusCode?: number;
durationMs?: number;
ip?: string;
remoteIp?: string;
userAgent?: string;
referer?: string | null;
raw: string; raw: string;
} }
@@ -39,6 +47,13 @@ function parseLine(line: string): LogEntry {
levelName: LEVEL_NAMES[level] ?? 'INFO', levelName: LEVEL_NAMES[level] ?? 'INFO',
msg: obj.msg ?? '', msg: obj.msg ?? '',
err: obj.err, err: obj.err,
method: obj.method,
url: obj.url,
statusCode: obj.statusCode,
durationMs: obj.durationMs,
ip: obj.ip ?? obj.remoteIp,
userAgent: obj.userAgent,
referer: obj.referer,
raw: line, raw: line,
}; };
} catch { } catch {
@@ -206,6 +221,44 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
</td> </td>
{/* Message */} {/* Message */}
<td className="px-3 py-0.5 text-gray-300 select-all"> <td className="px-3 py-0.5 text-gray-300 select-all">
{/* Extra fields for HTTP request logs */}
{entry.method && entry.url && (
<span className="mr-2">
<span className={`font-bold ${
entry.statusCode && entry.statusCode >= 500 ? 'text-red-400' :
entry.statusCode && entry.statusCode >= 400 ? 'text-amber-400' :
entry.statusCode && entry.statusCode >= 300 ? 'text-blue-400' :
'text-green-400'
}`}>{entry.method}</span>
<span className="text-gray-400 ml-1">{entry.url}</span>
{entry.statusCode && (
<span className={`ml-2 font-bold ${
entry.statusCode >= 500 ? 'text-red-400' :
entry.statusCode >= 400 ? 'text-amber-400' :
entry.statusCode >= 300 ? 'text-blue-400' :
'text-green-400'
}`}>{entry.statusCode}</span>
)}
{entry.durationMs !== undefined && (
<span className="ml-1 text-gray-500">{entry.durationMs}ms</span>
)}
<span className="ml-2 text-gray-600">·</span>
{entry.ip && (
<span className="ml-2 text-gray-500" title="IP origen">🌐 {entry.ip}</span>
)}
{entry.referer && (
<span className="ml-2 text-gray-500" title="Desde"> {entry.referer.replace(/^https?:\/\//, '').slice(0, 60)}</span>
)}
{entry.userAgent && (
<span className="ml-2 text-gray-600" title="User-Agent">
{entry.userAgent.includes('Postman') ? '📮 Postman' :
entry.userAgent.includes('curl') ? '📡 curl' :
entry.userAgent.includes('node') ? '⚙️ Node' :
'🖥️ Browser'}
</span>
)}
</span>
)}
{entry.msg} {entry.msg}
{entry.err && ( {entry.err && (
<span className="ml-2 text-red-400"> <span className="ml-2 text-red-400">

View File

@@ -0,0 +1,8 @@
# URL del backend para el proxy del POS.
# En producción (detrás de Traefik): no es necesario, usa el mismo origen.
# En desarrollo local o por IP LAN: REQUIRED — el proxy no sabe dónde está el backend.
# Copiar a .env.local y ajustar según tu entorno:
# Dev local: POS_BACKEND_URL=http://localhost:3000
# Dev por IP: POS_BACKEND_URL=http://192.168.18.93:3000
# Producción: dejar vacío (Traefik sirve todo en el mismo dominio)
# POS_BACKEND_URL=http://localhost:3000

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
allowedDevOrigins: ['192.168.18.93', 'localhost'], allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'tpv-mv.rikrdo.com'],
turbopack: { turbopack: {
root: __dirname, root: __dirname,
}, },

View File

@@ -3,9 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --port 3006", "dev": "next dev --port 3002",
"build": "next build", "build": "next build",
"start": "next start --port 3006" "start": "next start --port 3002"
}, },
"dependencies": { "dependencies": {
"next": "^16.3.1", "next": "^16.3.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -111,7 +111,10 @@ export default function RegisterPage() {
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [pendingSales, setPendingSales] = useState<PosPendingSale[]>([]); const [pendingSales, setPendingSales] = useState<PosPendingSale[]>([]);
const [todaySales, setTodaySales] = useState<PosPendingSale[]>([]);
const [loadingPending, setLoadingPending] = useState(false); const [loadingPending, setLoadingPending] = useState(false);
const [salesTab, setSalesTab] = useState<'pending' | 'day'>('day');
const [printingSaleId, setPrintingSaleId] = useState<string | null>(null);
const [restPaymentFor, setRestPaymentFor] = useState<PosPendingSale | null>(null); const [restPaymentFor, setRestPaymentFor] = useState<PosPendingSale | null>(null);
const [processingRest, setProcessingRest] = useState(false); const [processingRest, setProcessingRest] = useState(false);
const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>( const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>(
@@ -203,13 +206,38 @@ export default function RegisterPage() {
} }
}, [config?.terminal?.id, config?.session?.status]); }, [config?.terminal?.id, config?.session?.status]);
// Load today's completed sales for the sidebar
const loadTodaySales = useCallback(async () => {
if (!config?.terminal) {
setTodaySales([]);
return;
}
try {
const data = await posApi.listSales<{ items: PosPendingSale[] }>({
terminalId: config.terminal.id,
});
// Filter to today's date (completed or any state)
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayItems = (data.items ?? []).filter((sale) => {
const saleDate = new Date(sale.createdAt);
saleDate.setHours(0, 0, 0, 0);
return saleDate.getTime() === today.getTime();
});
setTodaySales(todayItems);
} catch {
setTodaySales([]);
}
}, [config?.terminal?.id]);
// POS-FIX-9: removed 10s polling — only manual refresh via button // POS-FIX-9: removed 10s polling — only manual refresh via button
// POS-FIX-6: re-run when config loads (terminalId changes from undefined to real id) // POS-FIX-6: re-run when config loads (terminalId changes from undefined to real id)
useEffect(() => { useEffect(() => {
if (!config?.terminal?.id || config.session?.status !== 'OPEN') return; if (!config?.terminal?.id || config.session?.status !== 'OPEN') return;
void loadPendingSales(); void loadPendingSales();
}, [config?.terminal?.id, config?.session?.status, loadPendingSales]); void loadTodaySales();
}, [config?.terminal?.id, config?.session?.status, loadPendingSales, loadTodaySales]);
useEffect(() => { useEffect(() => {
if (!config?.session || config.session.status !== 'OPEN') return; if (!config?.session || config.session.status !== 'OPEN') return;
@@ -580,6 +608,26 @@ export default function RegisterPage() {
} }
}; };
// POS-FIX-12: reprint ticket — load receipt modal for printing
const reprintSale = async (sale: PosPendingSale) => {
setPrintingSaleId(sale.id);
setError('');
try {
const result = await posApi.getReceipt<{ receipt: PosReceipt }>(sale.id);
// Show the receipt modal for printing
setReceipt(result.receipt);
// Also trigger print immediately after modal renders
setTimeout(() => {
const printBtn = document.querySelector('button[class*="bg-\[\#2D6A4F\]"]') as HTMLButtonElement | null;
if (printBtn) printBtn.click();
}, 100);
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo reimprimir el ticket');
} finally {
setPrintingSaleId(null);
}
};
// POS-FIX-8: recover a parked sale into the current cart // POS-FIX-8: recover a parked sale into the current cart
const recoverSale = async (sale: PosPendingSale) => { const recoverSale = async (sale: PosPendingSale) => {
// POS-FIX-9: if cart has items, show merge dialog instead of auto-merge // POS-FIX-9: if cart has items, show merge dialog instead of auto-merge
@@ -855,21 +903,59 @@ export default function RegisterPage() {
return ( return (
<div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}> <div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}>
<aside <aside
className="hidden w-64 shrink-0 flex-col border-r bg-amber-50/40 p-3 lg:flex" className="hidden w-[380px] shrink-0 flex-col border-r bg-gray-50 p-3 lg:flex"
aria-label="Pendientes de caja" aria-label="Pedidos"
> >
<div className="mb-3 flex items-center justify-between"> {/* Tab buttons */}
<div> <div className="mb-3 flex gap-1 rounded-xl bg-white p-1 shadow-sm">
<h2 className="text-sm font-bold text-[#2D6A4F]">Pendientes de caja</h2> <button
<p className="text-xs text-gray-500"> type="button"
Ventas con saldo pendiente en esta sesión. onClick={() => { setSalesTab('pending'); void loadPendingSales(); }}
</p> className={`flex-1 rounded-lg py-2 text-sm font-bold transition-colors ${
salesTab === 'pending'
? 'bg-[#2D6A4F] text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
Pendientes
{pendingSales.length > 0 && (
<span className={`ml-1 rounded-full px-1.5 py-0.5 text-xs ${
salesTab === 'pending' ? 'bg-white/20 text-white' : 'bg-amber-100 text-amber-700'
}`}>
{pendingSales.length}
</span>
)}
</button>
<button
type="button"
onClick={() => { setSalesTab('day'); void loadTodaySales(); }}
className={`flex-1 rounded-lg py-2 text-sm font-bold transition-colors ${
salesTab === 'day'
? 'bg-[#2D6A4F] text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
Día
{todaySales.length > 0 && (
<span className={`ml-1 rounded-full px-1.5 py-0.5 text-xs ${
salesTab === 'day' ? 'bg-white/20 text-white' : 'bg-green-100 text-green-700'
}`}>
{todaySales.length}
</span>
)}
</button>
</div> </div>
{/* Pending tab */}
{salesTab === 'pending' && (
<>
<div className="mb-3 flex items-center justify-between">
<p className="text-xs text-gray-500">Ventas con saldo pendiente.</p>
<button <button
type="button" type="button"
onClick={() => void loadPendingSales()} onClick={() => void loadPendingSales()}
disabled={loadingPending} disabled={loadingPending}
title="Actualizar pendientes" title="Actualizar"
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-40" className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-40"
> >
<svg className={loadingPending ? 'animate-spin' : ''} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}> <svg className={loadingPending ? 'animate-spin' : ''} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
@@ -886,7 +972,7 @@ export default function RegisterPage() {
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto"> <ul className="flex flex-1 flex-col gap-2 overflow-y-auto">
{pendingSales.map((sale) => { {pendingSales.map((sale) => {
const saleDate = new Date(sale.createdAt); const saleDate = new Date(sale.createdAt);
const dateStr = saleDate.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' }); const dateStr = saleDate.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
const timeStr = saleDate.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }); const timeStr = saleDate.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
const displayName = sale.posLabel || sale.receiptNumber || sale.id.slice(0, 8); const displayName = sale.posLabel || sale.receiptNumber || sale.id.slice(0, 8);
return ( return (
@@ -894,10 +980,20 @@ export default function RegisterPage() {
key={sale.id} key={sale.id}
className="rounded-xl border border-amber-200 bg-white p-3 text-sm shadow-sm" className="rounded-xl border border-amber-200 bg-white p-3 text-sm shadow-sm"
> >
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-gray-800">{displayName}</p> <p className="font-semibold text-gray-800">{displayName}</p>
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
📅 {dateStr} {timeStr} · Total {formatPrice(sale.totalCents)} · pendiente {formatPrice(sale.outstandingCents)} {dateStr} {timeStr}
</p> </p>
</div>
<div className="text-right">
<p className="font-bold text-gray-800">{formatPrice(sale.totalCents)}</p>
<p className="text-xs font-medium text-amber-600">
Pdo: {formatPrice(sale.outstandingCents)}
</p>
</div>
</div>
<div className="mt-2 flex gap-1"> <div className="mt-2 flex gap-1">
<button <button
type="button" type="button"
@@ -905,13 +1001,22 @@ export default function RegisterPage() {
disabled={recoveringSaleId === sale.id} disabled={recoveringSaleId === sale.id}
className="flex-1 rounded-lg bg-[#2D6A4F] px-2 py-1.5 text-xs font-bold text-white disabled:opacity-50" className="flex-1 rounded-lg bg-[#2D6A4F] px-2 py-1.5 text-xs font-bold text-white disabled:opacity-50"
> >
{recoveringSaleId === sale.id ? 'Cargando…' : 'Recuperar'} {recoveringSaleId === sale.id ? '...' : 'Recuperar'}
</button>
<button
type="button"
onClick={() => void reprintSale(sale)}
disabled={printingSaleId === sale.id}
className="flex items-center justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-xs font-bold text-blue-600 hover:bg-blue-100 disabled:opacity-50"
title="Reimprimir ticket"
>
{printingSaleId === sale.id ? '...' : '🖨'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setDeleteConfirmFor(sale)} onClick={() => setDeleteConfirmFor(sale)}
className="flex items-center justify-center rounded-lg bg-red-50 px-2 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100" className="flex items-center justify-center rounded-lg bg-red-50 px-2 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100"
aria-label="Eliminar venta pendiente" title="Eliminar"
> >
🗑 🗑
</button> </button>
@@ -921,6 +1026,142 @@ export default function RegisterPage() {
})} })}
</ul> </ul>
)} )}
</>
)}
{/* Today's sales tab */}
{salesTab === 'day' && (
<>
<div className="mb-3 flex items-center justify-between">
<p className="text-xs text-gray-500">Ventas completadas hoy.</p>
<button
type="button"
onClick={() => void loadTodaySales()}
title="Actualizar"
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M23 4v6h-6M1 20v-6h6" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{todaySales.length === 0 ? (
<p className="text-xs text-gray-500">Sin ventas hoy.</p>
) : (
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto">
{todaySales
.filter((s) => s.state !== 'PENDING')
.map((sale) => {
const saleDate = new Date(sale.createdAt);
const timeStr = saleDate.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
const displayName = sale.posLabel || sale.receiptNumber || sale.id.slice(0, 8);
const state = sale.state as string;
const isCompleted = state === 'COMPLETED';
const isCancelled = state === 'CANCELLED';
const isRefunded = state === 'REFUNDED' || state === 'PARTIAL_REFUNDED';
const stateStyles = isCancelled
? 'border-red-200 bg-red-50'
: isRefunded
? 'border-purple-200 bg-purple-50'
: 'border-green-200 bg-green-50';
const refundLabel = state === 'REFUNDED' ? '✕ Reintegrada' :
state === 'PARTIAL_REFUNDED' ? '↩ Reintegrada parcialmente' : '';
return (
<li
key={sale.id}
className={`rounded-xl border p-3 text-sm shadow-sm ${stateStyles}`}
>
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-gray-800">{displayName}</p>
<p className="text-xs text-gray-400">{timeStr}</p>
</div>
<div className="text-right">
<p className="font-bold text-gray-800">{formatPrice(sale.totalCents)}</p>
<p className={`text-xs font-medium ${
isCancelled ? 'text-red-600' :
isRefunded ? 'text-purple-600' : 'text-gray-500'
}`}>
{isCompleted ? '✓ Completada' :
isCancelled ? '✕ Cancelada' :
isRefunded ? refundLabel : sale.state}
</p>
</div>
</div>
{isCompleted && (
<div className="mt-2 flex gap-1">
<button
type="button"
onClick={() => void reprintSale(sale)}
disabled={printingSaleId === sale.id}
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-2 py-1.5 text-xs font-bold text-blue-600 hover:bg-blue-100 disabled:opacity-50"
>
{printingSaleId === sale.id ? '...' : '🖨'} Reimprimir
</button>
<button
type="button"
onClick={async () => {
try {
const result = await posApi.getReceipt<{ receipt: PosReceipt }>(sale.id);
setReturnOrder({ orderId: sale.id, receipt: result.receipt });
} catch {
setError('No se pudo cargar el ticket para reintegrar');
}
}}
className="flex items-center justify-center rounded-lg bg-amber-50 px-2 py-1.5 text-xs font-bold text-amber-600 hover:bg-amber-100"
title="Reintegrar"
>
Reintegrar
</button>
</div>
)}
{isRefunded && (
<div className="mt-2 flex gap-1">
<button
type="button"
onClick={() => void reprintSale(sale)}
disabled={printingSaleId === sale.id}
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-50 px-2 py-1.5 text-xs font-bold text-blue-600 hover:bg-blue-100 disabled:opacity-50"
>
{printingSaleId === sale.id ? '...' : '🖨'} Ver ticket
</button>
<button
type="button"
onClick={async () => {
try {
const [receiptResult, itemsResult] = await Promise.all([
posApi.getReceipt<{ receipt: PosReceipt }>(sale.id),
posApi.listOrderItems<{ items: RecoveredOrderItem[] }>(sale.id),
]);
// Show which items were returned
const returnedItems = itemsResult.items.filter((i) => i.returnedQuantity > 0);
if (returnedItems.length === 0) {
setError('No hay artículos reintegrados en este ticket');
} else {
setReturnOrder({ orderId: sale.id, receipt: receiptResult.receipt });
}
} catch {
setError('No se pudo cargar el detalle del reintegro');
}
}}
className="flex items-center justify-center rounded-lg bg-purple-50 px-2 py-1.5 text-xs font-bold text-purple-600 hover:bg-purple-100"
title="Ver reintegros"
>
📋 Ver reintegrado
</button>
</div>
)}
{isCancelled && (
<p className="mt-2 text-xs text-red-500">Ticket cancelado no imprimible</p>
)}
</li>
);
})}
</ul>
)}
</>
)}
</aside> </aside>
{/* POS-FIX-4: product added toast */} {/* POS-FIX-4: product added toast */}
{addedToast && ( {addedToast && (

View File

@@ -1,11 +1,16 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
// Use same host as browser to preserve cookies. In production, use the actual backend URL. // Resuelve la URL del backend:
// 1. POS_BACKEND_URL: explícito (dev por IP LAN, prod con traefik en otro host)
// 2. Mismo origen que el navegador: funciona en dev local y prod detrás de traefik
// (traefik sirve frontend+backend en el mismo dominio, routando /api/* al backend)
function getBackendUrl(request: NextRequest): string { function getBackendUrl(request: NextRequest): string {
if (process.env.POS_BACKEND_URL) { const backendUrl = process.env.POS_BACKEND_URL;
return `${process.env.POS_BACKEND_URL}/${apiPath(request)}${request.nextUrl.search}`; if (backendUrl) {
const base = backendUrl.replace(/\/$/, '');
return `${base}/${apiPath(request)}${request.nextUrl.search}`;
} }
// Dev: use same host/port as browser to keep cookies working // Fallback: mismo origen que el navegador (dev local y prod traefik)
const protocol = request.headers.get('x-forwarded-proto') ?? request.nextUrl.protocol; const protocol = request.headers.get('x-forwarded-proto') ?? request.nextUrl.protocol;
const host = request.headers.get('x-forwarded-host') ?? request.nextUrl.host; const host = request.headers.get('x-forwarded-host') ?? request.nextUrl.host;
return `${protocol}//${host}/${apiPath(request)}${request.nextUrl.search}`; return `${protocol}//${host}/${apiPath(request)}${request.nextUrl.search}`;

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { posApi } from '@/lib/api-client'; import { posApi } from '@/lib/api-client';
import { formatPrice } from '@/lib/money'; import { formatPrice } from '@/lib/money';
import type { PosReceipt } from '@/types/checkout'; import type { PosReceipt } from '@/types/checkout';
@@ -10,6 +10,7 @@ interface ReceiptModalProps {
initialEmail?: string; initialEmail?: string;
onDelivered: () => void; onDelivered: () => void;
onReturn?: (orderId: string, receipt: PosReceipt) => void; onReturn?: (orderId: string, receipt: PosReceipt) => void;
showActions?: boolean; // false when used just for printing
} }
export default function ReceiptModal({ export default function ReceiptModal({
@@ -17,13 +18,23 @@ export default function ReceiptModal({
initialEmail = '', initialEmail = '',
onDelivered, onDelivered,
onReturn, onReturn,
showActions = true,
}: ReceiptModalProps) { }: ReceiptModalProps) {
const articleRef = useRef<HTMLDivElement>(null);
const [email, setEmail] = useState(initialEmail); const [email, setEmail] = useState(initialEmail);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const printReceipt = () => { const printReceipt = () => {
// Hide everything except the ticket content before printing
document.querySelectorAll('.no-print').forEach((el) => {
(el as HTMLElement).style.visibility = 'hidden';
});
window.print(); window.print();
// Restore after printing
document.querySelectorAll('.no-print').forEach((el) => {
(el as HTMLElement).style.visibility = '';
});
onDelivered(); onDelivered();
}; };
@@ -49,8 +60,18 @@ export default function ReceiptModal({
aria-labelledby="receipt-title" aria-labelledby="receipt-title"
> >
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white p-6 shadow-2xl print:max-w-none print:rounded-none print:p-0 print:shadow-none"> <div className="mx-auto w-full max-w-xl rounded-2xl bg-white p-6 shadow-2xl print:max-w-none print:rounded-none print:p-0 print:shadow-none">
{/* Ticket content - only this gets printed */}
<div ref={articleRef} className="ticket-print-area">
<article className="space-y-4 text-sm text-gray-900"> <article className="space-y-4 text-sm text-gray-900">
<header className="border-b border-dashed border-gray-400 pb-4 text-center"> <header className="border-b border-dashed border-gray-400 pb-4 text-center">
{/* Logo */}
<div className="mb-3 flex justify-center">
<img
src="/images/logo-main.png"
alt="Logo"
className="h-16 object-contain print:h-12"
/>
</div>
{receipt.header && <p className="font-semibold">{receipt.header}</p>} {receipt.header && <p className="font-semibold">{receipt.header}</p>}
<h2 id="receipt-title" className="text-2xl font-bold"> <h2 id="receipt-title" className="text-2xl font-bold">
{receipt.company.name} {receipt.company.name}
@@ -156,15 +177,18 @@ export default function ReceiptModal({
{receipt.footer && <p className="font-semibold">{receipt.footer}</p>} {receipt.footer && <p className="font-semibold">{receipt.footer}</p>}
</footer> </footer>
</article> </article>
</div>
<div className="mt-6 space-y-3 border-t pt-5 print:hidden"> {/* Actions - hidden when printing */}
{showActions && (
<div className="no-print mt-6 space-y-3 border-t pt-5">
<p className="text-center text-sm text-gray-600"> <p className="text-center text-sm text-gray-600">
Imprime o envía el ticket para preparar la siguiente venta. Imprime o envía el ticket para preparar la siguiente venta.
</p> </p>
<button <button
type="button" type="button"
onClick={printReceipt} onClick={printReceipt}
className="min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white" className="no-print min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white"
> >
🖨 Imprimir ticket 🖨 Imprimir ticket
</button> </button>
@@ -172,12 +196,12 @@ export default function ReceiptModal({
<button <button
type="button" type="button"
onClick={() => onReturn(receipt.orderId, receipt)} onClick={() => onReturn(receipt.orderId, receipt)}
className="min-h-12 w-full rounded-xl border border-amber-300 bg-amber-50 font-bold text-amber-800" className="no-print min-h-12 w-full rounded-xl border border-amber-300 bg-amber-50 font-bold text-amber-800"
> >
Devolver artículos Devolver artículos
</button> </button>
)} )}
<form onSubmit={emailReceipt} className="flex gap-2"> <form onSubmit={emailReceipt} className="flex gap-2 no-print">
<label className="sr-only" htmlFor="receipt-email"> <label className="sr-only" htmlFor="receipt-email">
Email del ticket Email del ticket
</label> </label>
@@ -188,22 +212,42 @@ export default function ReceiptModal({
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
placeholder="cliente@email.es" placeholder="cliente@email.es"
className="min-h-14 min-w-0 flex-1 rounded-xl border-2 border-gray-200 px-4 outline-none focus:border-[#2D6A4F]" className="no-print min-h-14 min-w-0 flex-1 rounded-xl border-2 border-gray-200 px-4 outline-none focus:border-[#2D6A4F]"
/> />
<button <button
disabled={sending} disabled={sending}
className="min-h-14 rounded-xl bg-blue-600 px-5 font-bold text-white disabled:opacity-50" className="no-print min-h-14 rounded-xl bg-blue-600 px-5 font-bold text-white disabled:opacity-50"
> >
{sending ? 'Enviando…' : '✉ Enviar'} {sending ? 'Enviando…' : '✉ Enviar'}
</button> </button>
</form> </form>
{error && ( {error && (
<p className="text-sm font-medium text-red-600" aria-live="polite"> <p className="no-print text-sm font-medium text-red-600" aria-live="polite">
{error} {error}
</p> </p>
)} )}
</div> </div>
)}
</div> </div>
{/* Print-only styles */}
<style>{`
@media print {
body * {
visibility: hidden;
}
.ticket-print-area,
.ticket-print-area * {
visibility: visible;
}
.ticket-print-area {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
}
`}</style>
</div> </div>
); );
} }

View File

@@ -3,11 +3,12 @@
const API = '/api'; const API = '/api';
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const hasBody = init?.body !== undefined && init.body !== null && init.body !== '';
const res = await fetch(`${API}${path}`, { const res = await fetch(`${API}${path}`, {
...init, ...init,
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...(init?.headers ?? {}), ...(init?.headers ?? {}),
}, },
}); });
@@ -102,6 +103,12 @@ export const posApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ email }), body: JSON.stringify({ email }),
}), }),
/** Print receipt for any sale (pending, completed, refunded). */
printReceipt: (orderId: string) =>
apiFetch<{ url: string }>(`/pos/sales/${encodeURIComponent(orderId)}/print`),
/** Get full receipt for a sale (used for returns). */
getReceipt: <T>(orderId: string) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/receipt`),
}; };
export const authApi = { export const authApi = {

View File

@@ -5,7 +5,7 @@ import type { NextConfig } from 'next';
const frontendRoot = dirname(fileURLToPath(import.meta.url)); const frontendRoot = dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
allowedDevOrigins: ['192.168.18.93'], allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'shop-mv.rikrdo.com'],
turbopack: { turbopack: {
root: frontendRoot, root: frontendRoot,
}, },

View File

@@ -0,0 +1,22 @@
'use strict';
exports.shorthands = undefined;
exports.up = (pgm) => {
// Allow 'cancel' operation in inventory_movements for POS-FIX-8 (delete parked sale restores stock)
pgm.sql(`
ALTER TABLE inventory_movements
DROP CONSTRAINT IF EXISTS inventory_movements_operation_check,
ADD CONSTRAINT inventory_movements_operation_check
CHECK (operation = ANY (ARRAY['reserve'::text, 'release'::text, 'confirm'::text, 'set_available'::text, 'return'::text, 'cancel'::text]))
`);
};
exports.down = (pgm) => {
pgm.sql(`
ALTER TABLE inventory_movements
DROP CONSTRAINT IF EXISTS inventory_movements_operation_check,
ADD CONSTRAINT inventory_movements_operation_check
CHECK (operation = ANY (ARRAY['reserve'::text, 'release'::text, 'confirm'::text, 'set_available'::text, 'return'::text))
`);
};

View File

@@ -28,14 +28,15 @@ Commands:
start Install/build when needed, migrate, and start every service start Install/build when needed, migrate, and start every service
restart Stop managed processes, then start every service restart Stop managed processes, then start every service
status Show PID, process state, HTTP status, and URLs status Show PID, process state, HTTP status, and URLs
check Verify all services are responding (exit 0 if all OK)
stop Gracefully stop every managed HTTP process stop Gracefully stop every managed HTTP process
logs Follow all service logs (Ctrl-C exits without stopping services) logs Follow all service logs (Ctrl-C exits without stopping services)
urls Print localhost and LAN URLs urls Print localhost and LAN URLs
watch Auto-respawn any dead service every WATCH_INTERVAL seconds watch Auto-respawn any dead service every WATCH_INTERVAL seconds
Environment overrides: Environment overrides:
LAN_IP, BACKEND_PORT, FRONTEND_PORT, ADMIN_PORT, STOREFRONT_PORT LAN_IP, BACKEND_PORT, ADMIN_PORT, TPV_PORT, FRONTEND_PORT, STOREFRONT_PORT
MDV_RUNTIME_DIR, START_TIMEOUT MDV_RUNTIME_DIR, START_TIMEOUT, WATCH_INTERVAL
EOF EOF
} }
@@ -44,7 +45,7 @@ if [[ "$MODE" != "dev" && "$MODE" != "prod" ]]; then
exit 2 exit 2
fi fi
case "$ACTION" in case "$ACTION" in
start|restart|status|stop|logs|urls|watch) ;; start|restart|status|check|stop|logs|urls|watch) ;;
*) usage >&2; exit 2 ;; *) usage >&2; exit 2 ;;
esac esac
@@ -96,6 +97,17 @@ service_url() {
printf 'http://%s:%s%s\n' "$host" "$(service_port "$service")" "$(service_path "$service")" printf 'http://%s:%s%s\n' "$host" "$(service_port "$service")" "$(service_path "$service")"
} }
service_domain() {
# Returns the subdomain for Traefik/production access
case "$1" in
backend) echo "api-mv.rikrdo.com" ;;
admin) echo "admin-mv.rikrdo.com" ;;
tpv) echo "tpv-mv.rikrdo.com" ;;
frontend) echo "shop-mv.rikrdo.com" ;;
storefront) echo "seo-mv.rikrdo.com" ;;
esac
}
pid_file() { printf '%s/%s.pid\n' "$RUNTIME_DIR" "$1"; } pid_file() { printf '%s/%s.pid\n' "$RUNTIME_DIR" "$1"; }
log_file() { printf '%s/%s.log\n' "$RUNTIME_DIR" "$1"; } log_file() { printf '%s/%s.log\n' "$RUNTIME_DIR" "$1"; }
@@ -387,20 +399,71 @@ watch_loop() {
done done
} }
check_services() {
# Verify all services are responding with HTTP 2xx or 3xx
# Exit 0 if all OK, exit 1 if any service is down
local service url code failed=0
echo "Checking all services..."
for service in "${SERVICES[@]}"; do
url="$(service_url "$service")"
code="$(curl --max-time 5 -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"
if [[ "$code" =~ ^[23] ]]; then
echo "[OK] $service ($code) — $url"
else
echo "[FAIL] $service (HTTP $code) — $url"
failed=1
fi
done
echo
if [[ $failed -eq 0 ]]; then
echo "All services are up!"
return 0
else
echo "Some services are down."
return 1
fi
}
print_urls() { print_urls() {
# Detect if Traefik/production domains are reachable
local api_domain="$(service_domain backend)"
local admin_domain="$(service_domain admin)"
local tpv_domain="$(service_domain tpv)"
local shop_domain="$(service_domain frontend)"
local seo_domain="$(service_domain storefront)"
cat <<EOF cat <<EOF
Mode: $MODE Mode: $MODE
LAN IP: $LAN_ADDRESS LAN IP: $LAN_ADDRESS
Backend (API): http://$LAN_ADDRESS:$BACKEND_PORT/health ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Admin backoffice: http://$LAN_ADDRESS:$ADMIN_PORT/ PORTS (local/dev)
POS TPV: http://$LAN_ADDRESS:$TPV_PORT/ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Customer frontend: http://$LAN_ADDRESS:$FRONTEND_PORT/ ┌─────────────────────────────────────────────────────────────────────┐
SEO storefront: http://$LAN_ADDRESS:$STOREFRONT_PORT/ │ Service │ Port │ Local URL │
Backend health: http://$LAN_ADDRESS:$BACKEND_PORT/health ├─────────────────────────────────────────────────────────────────────┤
Swagger/OpenAPI: http://$LAN_ADDRESS:$BACKEND_PORT/docs │ Backend (API) │ $BACKEND_PORT │ http://127.0.0.1:$BACKEND_PORT/
│ Admin │ $ADMIN_PORT │ http://127.0.0.1:$ADMIN_PORT/ │
│ POS (TPV) │ $TPV_PORT │ http://127.0.0.1:$TPV_PORT/ │
│ Frontend │ $FRONTEND_PORT │ http://127.0.0.1:$FRONTEND_PORT/ │
│ Storefront │ $STOREFRONT_PORT │ http://127.0.0.1:$STOREFRONT_PORT/ │
└─────────────────────────────────────────────────────────────────────┘
Local equivalents use 127.0.0.1 with the same ports. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DOMAINS (Traefik/production)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
┌─────────────────────────────────────────────────────────────────────┐
│ Service │ Domain │ Auth │ Port │
├─────────────────────────────────────────────────────────────────────┤
│ Backend (API) │ https://$api_domain │ Authelia │ $BACKEND_PORT │
│ Admin │ https://$admin_domain │ Authelia │ $ADMIN_PORT │
│ POS (TPV) │ https://$tpv_domain │ Authelia │ $TPV_PORT │
│ Frontend │ https://$shop_domain │ Public │ $FRONTEND_PORT │
│ Storefront │ https://$seo_domain │ Public │ $STOREFRONT_PORT │
└─────────────────────────────────────────────────────────────────────┘
Backend health: https://$api_domain/health
Swagger docs: https://$api_domain/docs
EOF EOF
} }
@@ -417,6 +480,7 @@ case "$ACTION" in
start) start_all ;; start) start_all ;;
restart) stop_all; start_all ;; restart) stop_all; start_all ;;
status) status_all ;; status) status_all ;;
check) check_services ;;
stop) stop_all ;; stop) stop_all ;;
logs) follow_logs ;; logs) follow_logs ;;
urls) print_urls ;; urls) print_urls ;;

View File

@@ -120,6 +120,11 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
url: request.url, url: request.url,
statusCode: reply.statusCode, statusCode: reply.statusCode,
durationMs, durationMs,
ip: request.ip,
userAgent: request.headers['user-agent'],
referer: request.headers['referer'] ?? request.headers['referrer'] ?? null,
// Fastify's forwarded IP (works behind proxy if trusted proxy is set)
remoteIp: request.ip,
}, },
'request completed', 'request completed',
); );

View File

@@ -1854,9 +1854,9 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
[id], [id],
); );
await client.query( await client.query(
`INSERT INTO orders_order_events (order_id, event, actor_id, metadata) `INSERT INTO orders_order_history (order_id, event_type, message, actor_email)
VALUES ($1, 'CANCELLED', $2, $3)`, VALUES ($1, 'CANCELLED', 'Venta eliminada desde TPV', $2)`,
[id, user.id, JSON.stringify({ source: 'pos_delete' })], [id, user.email],
); );
await client.query('COMMIT'); await client.query('COMMIT');

View File

@@ -5,6 +5,7 @@ import type { NextConfig } from 'next';
const storefrontRoot = dirname(fileURLToPath(import.meta.url)); const storefrontRoot = dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'seo-mv.rikrdo.com'],
reactStrictMode: true, reactStrictMode: true,
turbopack: { turbopack: {
root: storefrontRoot, root: storefrontRoot,