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,71 +903,264 @@ 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>
<p className="text-xs text-gray-500">
Ventas con saldo pendiente en esta sesión.
</p>
</div>
<button <button
type="button" type="button"
onClick={() => void loadPendingSales()} onClick={() => { setSalesTab('pending'); void loadPendingSales(); }}
disabled={loadingPending} className={`flex-1 rounded-lg py-2 text-sm font-bold transition-colors ${
title="Actualizar pendientes" salesTab === 'pending'
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-40" ? 'bg-[#2D6A4F] text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
> >
<svg className={loadingPending ? 'animate-spin' : ''} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}> Pendientes
<path d="M23 4v6h-6M1 20v-6h6" strokeLinecap="round" strokeLinejoin="round"/> {pendingSales.length > 0 && (
<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"/> <span className={`ml-1 rounded-full px-1.5 py-0.5 text-xs ${
</svg> 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> </button>
</div> </div>
{loadingPending ? (
<p className="text-xs text-gray-500">Cargando</p> {/* Pending tab */}
) : pendingSales.length === 0 ? ( {salesTab === 'pending' && (
<p className="text-xs text-gray-500">Sin ventas pendientes.</p> <>
) : ( <div className="mb-3 flex items-center justify-between">
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto"> <p className="text-xs text-gray-500">Ventas con saldo pendiente.</p>
{pendingSales.map((sale) => { <button
const saleDate = new Date(sale.createdAt); type="button"
const dateStr = saleDate.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' }); onClick={() => void loadPendingSales()}
const timeStr = saleDate.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }); disabled={loadingPending}
const displayName = sale.posLabel || sale.receiptNumber || sale.id.slice(0, 8); title="Actualizar"
return ( className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-40"
<li
key={sale.id}
className="rounded-xl border border-amber-200 bg-white p-3 text-sm shadow-sm"
> >
<p className="font-semibold text-gray-800">{displayName}</p> <svg className={loadingPending ? 'animate-spin' : ''} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<p className="text-xs text-gray-400"> <path d="M23 4v6h-6M1 20v-6h6" strokeLinecap="round" strokeLinejoin="round"/>
📅 {dateStr} {timeStr} · Total {formatPrice(sale.totalCents)} · pendiente {formatPrice(sale.outstandingCents)} <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"/>
</p> </svg>
<div className="mt-2 flex gap-1"> </button>
<button </div>
type="button" {loadingPending ? (
onClick={() => void recoverSale(sale)} <p className="text-xs text-gray-500">Cargando</p>
disabled={recoveringSaleId === sale.id} ) : pendingSales.length === 0 ? (
className="flex-1 rounded-lg bg-[#2D6A4F] px-2 py-1.5 text-xs font-bold text-white disabled:opacity-50" <p className="text-xs text-gray-500">Sin ventas pendientes.</p>
) : (
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto">
{pendingSales.map((sale) => {
const saleDate = new Date(sale.createdAt);
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 displayName = sale.posLabel || sale.receiptNumber || sale.id.slice(0, 8);
return (
<li
key={sale.id}
className="rounded-xl border border-amber-200 bg-white p-3 text-sm shadow-sm"
> >
{recoveringSaleId === sale.id ? 'Cargando…' : 'Recuperar'} <div className="flex items-start justify-between">
</button> <div>
<button <p className="font-semibold text-gray-800">{displayName}</p>
type="button" <p className="text-xs text-gray-400">
onClick={() => setDeleteConfirmFor(sale)} {dateStr} {timeStr}
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" </p>
aria-label="Eliminar venta pendiente" </div>
> <div className="text-right">
🗑 <p className="font-bold text-gray-800">{formatPrice(sale.totalCents)}</p>
</button> <p className="text-xs font-medium text-amber-600">
</div> Pdo: {formatPrice(sale.outstandingCents)}
</li> </p>
); </div>
})} </div>
</ul> <div className="mt-2 flex gap-1">
<button
type="button"
onClick={() => void recoverSale(sale)}
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"
>
{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
type="button"
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"
title="Eliminar"
>
🗑
</button>
</div>
</li>
);
})}
</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 */}

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,161 +60,194 @@ 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">
<article className="space-y-4 text-sm text-gray-900"> {/* Ticket content - only this gets printed */}
<header className="border-b border-dashed border-gray-400 pb-4 text-center"> <div ref={articleRef} className="ticket-print-area">
{receipt.header && <p className="font-semibold">{receipt.header}</p>} <article className="space-y-4 text-sm text-gray-900">
<h2 id="receipt-title" className="text-2xl font-bold"> <header className="border-b border-dashed border-gray-400 pb-4 text-center">
{receipt.company.name} {/* Logo */}
</h2> <div className="mb-3 flex justify-center">
{receipt.company.address && <p>{receipt.company.address}</p>} <img
{receipt.company.taxId && <p>NIF/CIF: {receipt.company.taxId}</p>} src="/images/logo-main.png"
{(receipt.company.phone || receipt.company.email) && ( alt="Logo"
<p>{[receipt.company.phone, receipt.company.email].filter(Boolean).join(' · ')}</p> className="h-16 object-contain print:h-12"
)} />
</header>
<div className="grid grid-cols-2 gap-2 text-xs">
<p>
<strong>Ticket:</strong> {receipt.receiptNumber}
</p>
<p className="text-right">
<strong>Fecha:</strong> {new Date(receipt.issuedAt).toLocaleString('es-ES')}
</p>
<p>
<strong>Terminal:</strong> {receipt.terminal.name}
</p>
<p className="text-right">
<strong>Cajero:</strong> {receipt.cashier}
</p>
</div>
<table className="w-full border-collapse text-left">
<thead className="border-y border-dashed border-gray-400 text-xs uppercase">
<tr>
<th className="py-2">Artículo</th>
<th className="py-2 text-right">Cant.</th>
<th className="py-2 text-right">Subtotal</th>
</tr>
</thead>
<tbody>
{receipt.items.map((item, index) => (
<tr key={`${item.sku}-${index}`} className="border-b border-gray-100 align-top">
<td className="py-2">
<span className="font-medium">{item.name}</span>
<span className="block text-xs text-gray-500">
{formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' · libre' : ''}
</span>
{item.discountCents > 0 && (
<span className="block text-xs text-red-600">
Dto. {formatPrice(item.discountCents)}
</span>
)}
</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-right font-medium">{formatPrice(item.totalCents)}</td>
</tr>
))}
</tbody>
</table>
<div className="ml-auto w-full max-w-xs space-y-1">
<p className="flex justify-between">
<span>Subtotal</span>
<span>{formatPrice(receipt.subtotalCents)}</span>
</p>
{receipt.discountCents > 0 && (
<p className="flex justify-between text-red-600">
<span>Descuentos</span>
<span>{formatPrice(receipt.discountCents)}</span>
</p>
)}
<p className="flex justify-between">
<span>IVA</span>
<span>{formatPrice(receipt.taxCents)}</span>
</p>
<p className="flex justify-between border-t border-gray-900 pt-2 text-xl font-bold">
<span>Total</span>
<span>{formatPrice(receipt.totalCents)}</span>
</p>
</div>
<section className="border-y border-dashed border-gray-400 py-3">
<h3 className="mb-2 font-bold">Formas de pago</h3>
{receipt.payments.map((payment, index) => (
<div key={`${payment.methodCode}-${index}`} className="flex justify-between">
<span>{payment.methodLabel}</span>
<span>{formatPrice(payment.amountCents)}</span>
{payment.tenderedCents !== null && (
<span className="text-xs text-gray-500">
Entregado {formatPrice(payment.tenderedCents)}
</span>
)}
{payment.changeCents > 0 && (
<strong>Cambio {formatPrice(payment.changeCents)}</strong>
)}
</div> </div>
))} {receipt.header && <p className="font-semibold">{receipt.header}</p>}
{receipt.changeCents > 0 && ( <h2 id="receipt-title" className="text-2xl font-bold">
<p className="mt-2 flex justify-between rounded bg-amber-50 p-2 text-lg font-bold"> {receipt.company.name}
<span>Total a devolver</span> </h2>
<span>{formatPrice(receipt.changeCents)}</span> {receipt.company.address && <p>{receipt.company.address}</p>}
{receipt.company.taxId && <p>NIF/CIF: {receipt.company.taxId}</p>}
{(receipt.company.phone || receipt.company.email) && (
<p>{[receipt.company.phone, receipt.company.email].filter(Boolean).join(' · ')}</p>
)}
</header>
<div className="grid grid-cols-2 gap-2 text-xs">
<p>
<strong>Ticket:</strong> {receipt.receiptNumber}
</p> </p>
)} <p className="text-right">
</section> <strong>Fecha:</strong> {new Date(receipt.issuedAt).toLocaleString('es-ES')}
</p>
<p>
<strong>Terminal:</strong> {receipt.terminal.name}
</p>
<p className="text-right">
<strong>Cajero:</strong> {receipt.cashier}
</p>
</div>
<footer className="space-y-2 text-center text-xs"> <table className="w-full border-collapse text-left">
<p>{receipt.returnPolicy}</p> <thead className="border-y border-dashed border-gray-400 text-xs uppercase">
{receipt.footer && <p className="font-semibold">{receipt.footer}</p>} <tr>
</footer> <th className="py-2">Artículo</th>
</article> <th className="py-2 text-right">Cant.</th>
<th className="py-2 text-right">Subtotal</th>
</tr>
</thead>
<tbody>
{receipt.items.map((item, index) => (
<tr key={`${item.sku}-${index}`} className="border-b border-gray-100 align-top">
<td className="py-2">
<span className="font-medium">{item.name}</span>
<span className="block text-xs text-gray-500">
{formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' · libre' : ''}
</span>
{item.discountCents > 0 && (
<span className="block text-xs text-red-600">
Dto. {formatPrice(item.discountCents)}
</span>
)}
</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-right font-medium">{formatPrice(item.totalCents)}</td>
</tr>
))}
</tbody>
</table>
<div className="mt-6 space-y-3 border-t pt-5 print:hidden"> <div className="ml-auto w-full max-w-xs space-y-1">
<p className="text-center text-sm text-gray-600"> <p className="flex justify-between">
Imprime o envía el ticket para preparar la siguiente venta. <span>Subtotal</span>
</p> <span>{formatPrice(receipt.subtotalCents)}</span>
<button </p>
type="button" {receipt.discountCents > 0 && (
onClick={printReceipt} <p className="flex justify-between text-red-600">
className="min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white" <span>Descuentos</span>
> <span>{formatPrice(receipt.discountCents)}</span>
🖨 Imprimir ticket </p>
</button> )}
{onReturn && !receipt.isReturn && ( <p className="flex justify-between">
<span>IVA</span>
<span>{formatPrice(receipt.taxCents)}</span>
</p>
<p className="flex justify-between border-t border-gray-900 pt-2 text-xl font-bold">
<span>Total</span>
<span>{formatPrice(receipt.totalCents)}</span>
</p>
</div>
<section className="border-y border-dashed border-gray-400 py-3">
<h3 className="mb-2 font-bold">Formas de pago</h3>
{receipt.payments.map((payment, index) => (
<div key={`${payment.methodCode}-${index}`} className="flex justify-between">
<span>{payment.methodLabel}</span>
<span>{formatPrice(payment.amountCents)}</span>
{payment.tenderedCents !== null && (
<span className="text-xs text-gray-500">
Entregado {formatPrice(payment.tenderedCents)}
</span>
)}
{payment.changeCents > 0 && (
<strong>Cambio {formatPrice(payment.changeCents)}</strong>
)}
</div>
))}
{receipt.changeCents > 0 && (
<p className="mt-2 flex justify-between rounded bg-amber-50 p-2 text-lg font-bold">
<span>Total a devolver</span>
<span>{formatPrice(receipt.changeCents)}</span>
</p>
)}
</section>
<footer className="space-y-2 text-center text-xs">
<p>{receipt.returnPolicy}</p>
{receipt.footer && <p className="font-semibold">{receipt.footer}</p>}
</footer>
</article>
</div>
{/* 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">
Imprime o envía el ticket para preparar la siguiente venta.
</p>
<button <button
type="button" type="button"
onClick={() => onReturn(receipt.orderId, receipt)} onClick={printReceipt}
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-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white"
> >
Devolver artículos 🖨 Imprimir ticket
</button> </button>
)} {onReturn && !receipt.isReturn && (
<form onSubmit={emailReceipt} className="flex gap-2"> <button
<label className="sr-only" htmlFor="receipt-email"> type="button"
Email del ticket onClick={() => onReturn(receipt.orderId, receipt)}
</label> className="no-print min-h-12 w-full rounded-xl border border-amber-300 bg-amber-50 font-bold text-amber-800"
<input >
id="receipt-email" Devolver artículos
type="email" </button>
required )}
value={email} <form onSubmit={emailReceipt} className="flex gap-2 no-print">
onChange={(event) => setEmail(event.target.value)} <label className="sr-only" htmlFor="receipt-email">
placeholder="cliente@email.es" Email del ticket
className="min-h-14 min-w-0 flex-1 rounded-xl border-2 border-gray-200 px-4 outline-none focus:border-[#2D6A4F]" </label>
/> <input
<button id="receipt-email"
disabled={sending} type="email"
className="min-h-14 rounded-xl bg-blue-600 px-5 font-bold text-white disabled:opacity-50" required
> value={email}
{sending ? 'Enviando…' : '✉ Enviar'} onChange={(event) => setEmail(event.target.value)}
</button> placeholder="cliente@email.es"
</form> 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]"
{error && ( />
<p className="text-sm font-medium text-red-600" aria-live="polite"> <button
{error} disabled={sending}
</p> className="no-print min-h-14 rounded-xl bg-blue-600 px-5 font-bold text-white disabled:opacity-50"
)} >
</div> {sending ? 'Enviando…' : '✉ Enviar'}
</button>
</form>
{error && (
<p className="no-print text-sm font-medium text-red-600" aria-live="polite">
{error}
</p>
)}
</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,