feat(POS-FIX-6): completed feature
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
8
project/apps/pos/.env.example
Normal file
8
project/apps/pos/.env.example
Normal 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
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
project/apps/pos/public/images/logo-main.png
Normal file
BIN
project/apps/pos/public/images/logo-main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -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 */}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
22
project/migrations/060_inventory_allow_cancel_operation.js
Normal file
22
project/migrations/060_inventory_allow_cancel_operation.js
Normal 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))
|
||||
`);
|
||||
};
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user