feat(POS-FIX-6): completed feature
This commit is contained in:
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user