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

@@ -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 = {