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,
"close": true
},
"completed_at": "2026-08-23T07:02:05Z"
"completed_at": "2026-08-24T13:40:01Z"
},
{
"id": "F-201",

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
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
// legacy project/frontend/package-lock.json; without an explicit root,
// Next.js 16 may infer the wrong workspace during production builds.

View File

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

View File

@@ -153,7 +153,7 @@ export default function OrdersPage() {
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
{['ID', 'Fecha', 'Total', 'Estado'].map((h) => (
{['ID', 'Fecha', 'Cliente', 'Total', 'Estado'].map((h) => (
<th
key={h}
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>
</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">
<p className="text-sm font-bold text-gray-900">{formatPrice(o.totalCents)}</p>
</td>

View File

@@ -7,6 +7,14 @@ interface LogEntry {
levelName: string;
msg: string;
err?: Record<string, unknown>;
method?: string;
url?: string;
statusCode?: number;
durationMs?: number;
ip?: string;
remoteIp?: string;
userAgent?: string;
referer?: string | null;
raw: string;
}
@@ -39,6 +47,13 @@ function parseLine(line: string): LogEntry {
levelName: LEVEL_NAMES[level] ?? 'INFO',
msg: obj.msg ?? '',
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,
};
} catch {
@@ -206,6 +221,44 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
</td>
{/* Message */}
<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.err && (
<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';
const nextConfig: NextConfig = {
allowedDevOrigins: ['192.168.18.93', 'localhost'],
allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'tpv-mv.rikrdo.com'],
turbopack: {
root: __dirname,
},

View File

@@ -3,9 +3,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3006",
"dev": "next dev --port 3002",
"build": "next build",
"start": "next start --port 3006"
"start": "next start --port 3002"
},
"dependencies": {
"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 [error, setError] = useState('');
const [pendingSales, setPendingSales] = useState<PosPendingSale[]>([]);
const [todaySales, setTodaySales] = useState<PosPendingSale[]>([]);
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 [processingRest, setProcessingRest] = useState(false);
const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>(
@@ -203,13 +206,38 @@ export default function RegisterPage() {
}
}, [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-6: re-run when config loads (terminalId changes from undefined to real id)
useEffect(() => {
if (!config?.terminal?.id || config.session?.status !== 'OPEN') return;
void loadPendingSales();
}, [config?.terminal?.id, config?.session?.status, loadPendingSales]);
void loadTodaySales();
}, [config?.terminal?.id, config?.session?.status, loadPendingSales, loadTodaySales]);
useEffect(() => {
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
const recoverSale = async (sale: PosPendingSale) => {
// POS-FIX-9: if cart has items, show merge dialog instead of auto-merge
@@ -855,71 +903,264 @@ export default function RegisterPage() {
return (
<div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}>
<aside
className="hidden w-64 shrink-0 flex-col border-r bg-amber-50/40 p-3 lg:flex"
aria-label="Pendientes de caja"
className="hidden w-[380px] shrink-0 flex-col border-r bg-gray-50 p-3 lg:flex"
aria-label="Pedidos"
>
<div className="mb-3 flex items-center justify-between">
<div>
<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>
{/* Tab buttons */}
<div className="mb-3 flex gap-1 rounded-xl bg-white p-1 shadow-sm">
<button
type="button"
onClick={() => void loadPendingSales()}
disabled={loadingPending}
title="Actualizar pendientes"
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-40"
onClick={() => { setSalesTab('pending'); void loadPendingSales(); }}
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'
}`}
>
<svg className={loadingPending ? 'animate-spin' : ''} 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>
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>
{loadingPending ? (
<p className="text-xs text-gray-500">Cargando</p>
) : pendingSales.length === 0 ? (
<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', year: '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"
{/* 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
type="button"
onClick={() => void loadPendingSales()}
disabled={loadingPending}
title="Actualizar"
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-40"
>
<p className="font-semibold text-gray-800">{displayName}</p>
<p className="text-xs text-gray-400">
📅 {dateStr} {timeStr} · Total {formatPrice(sale.totalCents)} · pendiente {formatPrice(sale.outstandingCents)}
</p>
<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"
<svg className={loadingPending ? 'animate-spin' : ''} 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>
{loadingPending ? (
<p className="text-xs text-gray-500">Cargando</p>
) : pendingSales.length === 0 ? (
<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'}
</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"
aria-label="Eliminar venta pendiente"
>
🗑
</button>
</div>
</li>
);
})}
</ul>
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-gray-800">{displayName}</p>
<p className="text-xs text-gray-400">
{dateStr} {timeStr}
</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">
<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>
{/* POS-FIX-4: product added toast */}

View File

@@ -1,11 +1,16 @@
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 {
if (process.env.POS_BACKEND_URL) {
return `${process.env.POS_BACKEND_URL}/${apiPath(request)}${request.nextUrl.search}`;
const backendUrl = process.env.POS_BACKEND_URL;
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 host = request.headers.get('x-forwarded-host') ?? request.nextUrl.host;
return `${protocol}//${host}/${apiPath(request)}${request.nextUrl.search}`;

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { posApi } from '@/lib/api-client';
import { formatPrice } from '@/lib/money';
import type { PosReceipt } from '@/types/checkout';
@@ -10,6 +10,7 @@ interface ReceiptModalProps {
initialEmail?: string;
onDelivered: () => void;
onReturn?: (orderId: string, receipt: PosReceipt) => void;
showActions?: boolean; // false when used just for printing
}
export default function ReceiptModal({
@@ -17,13 +18,23 @@ export default function ReceiptModal({
initialEmail = '',
onDelivered,
onReturn,
showActions = true,
}: ReceiptModalProps) {
const articleRef = useRef<HTMLDivElement>(null);
const [email, setEmail] = useState(initialEmail);
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
const printReceipt = () => {
// Hide everything except the ticket content before printing
document.querySelectorAll('.no-print').forEach((el) => {
(el as HTMLElement).style.visibility = 'hidden';
});
window.print();
// Restore after printing
document.querySelectorAll('.no-print').forEach((el) => {
(el as HTMLElement).style.visibility = '';
});
onDelivered();
};
@@ -49,161 +60,194 @@ export default function ReceiptModal({
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">
<article className="space-y-4 text-sm text-gray-900">
<header className="border-b border-dashed border-gray-400 pb-4 text-center">
{receipt.header && <p className="font-semibold">{receipt.header}</p>}
<h2 id="receipt-title" className="text-2xl font-bold">
{receipt.company.name}
</h2>
{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 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>
)}
{/* Ticket content - only this gets printed */}
<div ref={articleRef} className="ticket-print-area">
<article className="space-y-4 text-sm text-gray-900">
<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.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>
{receipt.header && <p className="font-semibold">{receipt.header}</p>}
<h2 id="receipt-title" className="text-2xl font-bold">
{receipt.company.name}
</h2>
{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>
)}
</section>
<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>
<footer className="space-y-2 text-center text-xs">
<p>{receipt.returnPolicy}</p>
{receipt.footer && <p className="font-semibold">{receipt.footer}</p>}
</footer>
</article>
<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="mt-6 space-y-3 border-t pt-5 print:hidden">
<p className="text-center text-sm text-gray-600">
Imprime o envía el ticket para preparar la siguiente venta.
</p>
<button
type="button"
onClick={printReceipt}
className="min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white"
>
🖨 Imprimir ticket
</button>
{onReturn && !receipt.isReturn && (
<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>
))}
{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
type="button"
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"
onClick={printReceipt}
className="no-print min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white"
>
Devolver artículos
🖨 Imprimir ticket
</button>
)}
<form onSubmit={emailReceipt} className="flex gap-2">
<label className="sr-only" htmlFor="receipt-email">
Email del ticket
</label>
<input
id="receipt-email"
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
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]"
/>
<button
disabled={sending}
className="min-h-14 rounded-xl bg-blue-600 px-5 font-bold text-white disabled:opacity-50"
>
{sending ? 'Enviando…' : '✉ Enviar'}
</button>
</form>
{error && (
<p className="text-sm font-medium text-red-600" aria-live="polite">
{error}
</p>
)}
</div>
{onReturn && !receipt.isReturn && (
<button
type="button"
onClick={() => onReturn(receipt.orderId, receipt)}
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
</button>
)}
<form onSubmit={emailReceipt} className="flex gap-2 no-print">
<label className="sr-only" htmlFor="receipt-email">
Email del ticket
</label>
<input
id="receipt-email"
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="cliente@email.es"
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
disabled={sending}
className="no-print min-h-14 rounded-xl bg-blue-600 px-5 font-bold text-white disabled:opacity-50"
>
{sending ? 'Enviando…' : '✉ Enviar'}
</button>
</form>
{error && (
<p className="no-print text-sm font-medium text-red-600" aria-live="polite">
{error}
</p>
)}
</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>
);
}

View File

@@ -3,11 +3,12 @@
const API = '/api';
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}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...(init?.headers ?? {}),
},
});
@@ -102,6 +103,12 @@ export const posApi = {
method: 'POST',
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 = {

View File

@@ -5,7 +5,7 @@ import type { NextConfig } from 'next';
const frontendRoot = dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = {
allowedDevOrigins: ['192.168.18.93'],
allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'shop-mv.rikrdo.com'],
turbopack: {
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
restart Stop managed processes, then start every service
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
logs Follow all service logs (Ctrl-C exits without stopping services)
urls Print localhost and LAN URLs
watch Auto-respawn any dead service every WATCH_INTERVAL seconds
Environment overrides:
LAN_IP, BACKEND_PORT, FRONTEND_PORT, ADMIN_PORT, STOREFRONT_PORT
MDV_RUNTIME_DIR, START_TIMEOUT
LAN_IP, BACKEND_PORT, ADMIN_PORT, TPV_PORT, FRONTEND_PORT, STOREFRONT_PORT
MDV_RUNTIME_DIR, START_TIMEOUT, WATCH_INTERVAL
EOF
}
@@ -44,7 +45,7 @@ if [[ "$MODE" != "dev" && "$MODE" != "prod" ]]; then
exit 2
fi
case "$ACTION" in
start|restart|status|stop|logs|urls|watch) ;;
start|restart|status|check|stop|logs|urls|watch) ;;
*) usage >&2; exit 2 ;;
esac
@@ -96,6 +97,17 @@ service_url() {
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"; }
log_file() { printf '%s/%s.log\n' "$RUNTIME_DIR" "$1"; }
@@ -387,20 +399,71 @@ watch_loop() {
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() {
# 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
Mode: $MODE
LAN IP: $LAN_ADDRESS
Backend (API): http://$LAN_ADDRESS:$BACKEND_PORT/health
Admin backoffice: http://$LAN_ADDRESS:$ADMIN_PORT/
POS TPV: http://$LAN_ADDRESS:$TPV_PORT/
Customer frontend: http://$LAN_ADDRESS:$FRONTEND_PORT/
SEO storefront: http://$LAN_ADDRESS:$STOREFRONT_PORT/
Backend health: http://$LAN_ADDRESS:$BACKEND_PORT/health
Swagger/OpenAPI: http://$LAN_ADDRESS:$BACKEND_PORT/docs
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PORTS (local/dev)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
┌─────────────────────────────────────────────────────────────────────┐
│ Service │ Port │ Local URL │
├─────────────────────────────────────────────────────────────────────┤
│ 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
}
@@ -417,6 +480,7 @@ case "$ACTION" in
start) start_all ;;
restart) stop_all; start_all ;;
status) status_all ;;
check) check_services ;;
stop) stop_all ;;
logs) follow_logs ;;
urls) print_urls ;;

View File

@@ -120,6 +120,11 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
url: request.url,
statusCode: reply.statusCode,
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',
);

View File

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

View File

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