Files
mercadodevida/project/apps/pos/src/components/ReceiptModal.tsx
2026-08-25 06:35:39 +02:00

259 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useRef, useState } from 'react';
import { posApi } from '@/lib/api-client';
import { formatPrice } from '@/lib/money';
import type { PosReceipt } from '@/types/checkout';
interface ReceiptModalProps {
receipt: PosReceipt;
initialEmail?: string;
onDelivered: () => void;
onReturn?: (orderId: string, receipt: PosReceipt) => void;
showActions?: boolean; // false when used just for printing
}
export default function ReceiptModal({
receipt,
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();
};
const emailReceipt = async (event: React.FormEvent) => {
event.preventDefault();
setSending(true);
setError('');
try {
await posApi.emailReceipt(receipt.orderId, email.trim());
onDelivered();
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo enviar el ticket');
} finally {
setSending(false);
}
};
return (
<div
className="fixed inset-0 z-[60] overflow-y-auto bg-black/50 p-4 print:static print:bg-white print:p-0"
role="dialog"
aria-modal="true"
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">
{/* 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">
{/* Timestamp */}
<div className="flex justify-between items-center mb-2 text-xs text-gray-500">
<span>Ticket #{receipt.receiptNumber}</span>
<span>{new Date(receipt.issuedAt).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })}</span>
</div>
{/* Logo */}
<div className="mb-3 flex justify-center">
<img
src={receipt.logoUrl ?? '/images/logo-main.png'}
alt="Logo"
className="h-16 object-contain print:h-12"
/>
</div>
{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?.split('@')[0] ?? '—'}
</p>
</div>
<table className="w-full border-collapse text-left">
<thead className="border-y border-dashed border-gray-400 text-xs uppercase">
<tr>
<th className="py-2">Artículo</th>
<th className="py-2 text-right">Cant.</th>
<th className="py-2 text-right">Subtotal</th>
</tr>
</thead>
<tbody>
{receipt.items.map((item, index) => (
<tr key={`${item.sku}-${index}`} className="border-b border-gray-100 align-top">
<td className="py-2">
<span className="font-medium">{item.name}</span>
<span className="block text-xs text-gray-500">
{formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' · libre' : ''}
</span>
{item.discountCents > 0 && (
<span className="block text-xs text-red-600">
Dto. {formatPrice(item.discountCents)}
</span>
)}
</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-right font-medium">{formatPrice(item.totalCents)}</td>
</tr>
))}
</tbody>
</table>
<div className="ml-auto w-full max-w-xs space-y-1">
<p className="flex justify-between">
<span>Subtotal</span>
<span>{formatPrice(receipt.subtotalCents)}</span>
</p>
{receipt.discountCents > 0 && (
<p className="flex justify-between text-red-600">
<span>Descuentos</span>
<span>{formatPrice(receipt.discountCents)}</span>
</p>
)}
<p className="flex justify-between">
<span>IVA</span>
<span>{formatPrice(receipt.taxCents)}</span>
</p>
<p className="flex justify-between border-t border-gray-900 pt-2 text-xl font-bold">
<span>Total</span>
<span>{formatPrice(receipt.totalCents)}</span>
</p>
</div>
<section className="border-y border-dashed border-gray-400 py-3">
<h3 className="mb-2 font-bold">Formas de pago</h3>
{receipt.payments.map((payment, index) => (
<div key={`${payment.methodCode}-${index}`} className="flex justify-between">
<span>{payment.methodLabel}</span>
<span>{formatPrice(payment.amountCents)}</span>
{payment.tenderedCents !== null && (
<span className="text-xs text-gray-500">
Entregado {formatPrice(payment.tenderedCents)}
</span>
)}
{payment.changeCents > 0 && (
<strong>Cambio {formatPrice(payment.changeCents)}</strong>
)}
</div>
))}
{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={printReceipt}
className="no-print min-h-14 w-full rounded-xl bg-[#2D6A4F] font-bold text-white"
>
🖨 Imprimir ticket
</button>
{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>
);
}