259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
'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>
|
||
);
|
||
}
|